Hermes-agent
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Guard against a local utils/ (or other package) in CWD shadowing installed
|
||||
# hermes modules. hermes_cli sets HERMES_PYTHON_SRC_ROOT before spawning this
|
||||
# subprocess; inserting it first ensures the installed packages win.
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
# Strip '' and '.' — both resolve to CWD at import time and can let a local
|
||||
# directory shadow installed packages.
|
||||
sys.path = [p for p in sys.path if p not in {"", "."}]
|
||||
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from tui_gateway import server
|
||||
from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json
|
||||
from tui_gateway.transport import TeeTransport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Handle for the background MCP tool-discovery thread (see main()). The first
|
||||
# agent build briefly joins this so already-spawning fast servers land before
|
||||
# the agent snapshots its tool list (see wait_for_mcp_discovery).
|
||||
_mcp_discovery_thread = None
|
||||
|
||||
|
||||
def _install_sidecar_publisher() -> None:
|
||||
"""Mirror every dispatcher emit to the dashboard sidebar via WS.
|
||||
|
||||
Activated by `HERMES_TUI_SIDECAR_URL`, set by the dashboard's
|
||||
``/api/pty`` endpoint when a chat tab passes a ``channel`` query param.
|
||||
Best-effort: connect failure or runtime drop falls back to stdio-only.
|
||||
"""
|
||||
url = os.environ.get("HERMES_TUI_SIDECAR_URL")
|
||||
|
||||
if not url:
|
||||
return
|
||||
|
||||
from tui_gateway.event_publisher import WsPublisherTransport
|
||||
|
||||
server._stdio_transport = TeeTransport(
|
||||
server._stdio_transport, WsPublisherTransport(url)
|
||||
)
|
||||
|
||||
|
||||
# How long to wait for orderly shutdown (atexit + finalisers) before
|
||||
# falling back to ``os._exit(0)`` so a wedged worker mid-flush can't
|
||||
# strand the process. 1s covers the gateway's own shutdown work
|
||||
# (thread-pool drain + session finalize) on every machine we've
|
||||
# tested; override via ``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S`` if a
|
||||
# slower environment needs more headroom (e.g. encrypted disks
|
||||
# flushing checkpoints) and accept that a longer grace also means a
|
||||
# longer wait when shutdown actually deadlocks.
|
||||
_DEFAULT_SHUTDOWN_GRACE_S = 1.0
|
||||
|
||||
|
||||
def _shutdown_grace_seconds() -> float:
|
||||
raw = (os.environ.get("HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S") or "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_SHUTDOWN_GRACE_S
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_SHUTDOWN_GRACE_S
|
||||
return value if value > 0 else _DEFAULT_SHUTDOWN_GRACE_S
|
||||
|
||||
|
||||
def _log_signal(signum: int, frame) -> None:
|
||||
"""Capture WHICH thread and WHERE a termination signal hit us.
|
||||
|
||||
SIG_DFL for SIGPIPE kills the process silently the instant any
|
||||
background thread (TTS playback, beep, voice status emitter, etc.)
|
||||
writes to a stdout the TUI has stopped reading. Without this
|
||||
handler the gateway-exited banner in the TUI has no trace — the
|
||||
crash log never sees a Python exception because the kernel reaps
|
||||
the process before the interpreter runs anything.
|
||||
|
||||
Termination semantics: ``sys.exit(0)`` here used to race the worker
|
||||
pool — a thread holding ``_stdout_lock`` mid-flush would block the
|
||||
interpreter shutdown indefinitely. We now log the stack, give the
|
||||
process the configured shutdown grace
|
||||
(``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S``, default
|
||||
``_DEFAULT_SHUTDOWN_GRACE_S``) to drain naturally on a background
|
||||
thread, and fall back to ``os._exit(0)`` so a wedged write/flush
|
||||
can never strand the process.
|
||||
"""
|
||||
# SIGPIPE and SIGHUP don't exist on Windows — build the lookup
|
||||
# dict from attributes that actually exist on the current platform.
|
||||
_signal_names: dict[int, str] = {}
|
||||
for _attr in ("SIGPIPE", "SIGTERM", "SIGHUP", "SIGINT", "SIGBREAK"):
|
||||
_sig = getattr(signal, _attr, None)
|
||||
if _sig is not None:
|
||||
_signal_names[int(_sig)] = _attr
|
||||
name = _signal_names.get(signum, f"signal {signum}")
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
|
||||
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"\n=== {name} received · {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n"
|
||||
)
|
||||
if frame is not None:
|
||||
f.write("main-thread stack at signal delivery:\n")
|
||||
traceback.print_stack(frame, file=f)
|
||||
# All live threads — signal may have been triggered by a
|
||||
# background thread (write to broken stdout from TTS, etc.).
|
||||
import threading as _threading
|
||||
for tid, th in _threading._active.items():
|
||||
f.write(f"\n--- thread {th.name} (id={tid}) ---\n")
|
||||
f.write("".join(traceback.format_stack(sys._current_frames().get(tid))))
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[gateway-signal] {name}", file=sys.stderr, flush=True)
|
||||
|
||||
import threading as _threading
|
||||
|
||||
def _hard_exit() -> None:
|
||||
# If a worker thread is still mid-flush on a half-closed pipe,
|
||||
# ``sys.exit(0)`` would wait forever for it to drop the GIL on
|
||||
# interpreter shutdown. ``os._exit`` skips atexit handlers but
|
||||
# breaks the deadlock. The crash log + stderr line above are
|
||||
# the forensic trail.
|
||||
os._exit(0)
|
||||
|
||||
timer = _threading.Timer(_shutdown_grace_seconds(), _hard_exit)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
try:
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
# Re-raise so the main-thread interpreter unwinds and runs
|
||||
# atexit + finalisers inside the grace window. Python signal
|
||||
# handlers always run on the main thread, but a worker thread
|
||||
# holding ``_stdout_lock`` mid-flush can keep that unwind
|
||||
# waiting indefinitely; the daemon timer above is the safety
|
||||
# net for that exact case.
|
||||
raise
|
||||
|
||||
|
||||
# SIGPIPE: ignore, don't exit. The old SIG_DFL killed the process
|
||||
# silently whenever a *background* thread (TTS playback chain, voice
|
||||
# debug stderr emitter, beep thread) wrote to a pipe the TUI had gone
|
||||
# quiet on — even though the main thread was perfectly fine waiting on
|
||||
# stdin. Ignoring the signal lets Python raise BrokenPipeError on the
|
||||
# offending write (write_json already handles that with a clean
|
||||
# sys.exit(0) + _log_exit), which keeps the gateway alive as long as
|
||||
# the main command pipe is still readable. Terminal signals still
|
||||
# route through _log_signal so kills and hangups are diagnosable.
|
||||
#
|
||||
# SIGPIPE and SIGHUP don't exist on Windows; guard each installation
|
||||
# with hasattr so ``python -m tui_gateway.entry`` (spawned by
|
||||
# ``hermes --tui``) imports cleanly there. SIGBREAK (Windows' Ctrl+Break)
|
||||
# is installed when available as a weaker equivalent of SIGHUP.
|
||||
if hasattr(signal, "SIGPIPE"):
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||
if hasattr(signal, "SIGTERM"):
|
||||
signal.signal(signal.SIGTERM, _log_signal)
|
||||
if hasattr(signal, "SIGHUP"):
|
||||
signal.signal(signal.SIGHUP, _log_signal)
|
||||
elif hasattr(signal, "SIGBREAK"):
|
||||
# Windows-only: Ctrl+Break in a console window delivers SIGBREAK.
|
||||
# Route it through the same handler so kills are diagnosable.
|
||||
signal.signal(signal.SIGBREAK, _log_signal)
|
||||
if hasattr(signal, "SIGINT"):
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
|
||||
|
||||
def _log_exit(reason: str) -> None:
|
||||
"""Record why the gateway subprocess is shutting down.
|
||||
|
||||
Three exit paths (startup write fail, parse-error-response write fail,
|
||||
dispatch-response write fail, stdin EOF) all collapse into a silent
|
||||
sys.exit(0) here. Without this trail the TUI shows "gateway exited"
|
||||
with no actionable clue about WHICH broken pipe or WHICH message
|
||||
triggered it — the main reason voice-mode turns look like phantom
|
||||
crashes when the real story is "TUI read pipe closed on this event".
|
||||
"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
|
||||
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"\n=== gateway exit · {time.strftime('%Y-%m-%d %H:%M:%S')} "
|
||||
f"· reason={reason} ===\n"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[gateway-exit] {reason}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def wait_for_mcp_discovery(timeout: float = 0.75) -> None:
|
||||
"""Briefly block until background MCP discovery finishes, up to ``timeout``.
|
||||
|
||||
MCP discovery runs in a daemon thread spawned at startup (see main()) so a
|
||||
slow/dead server can't freeze ``gateway.ready``. But the agent snapshots
|
||||
its tool list ONCE at build time and never re-reads it, so a reachable-but-
|
||||
slow server that finishes connecting *after* the first prompt would be
|
||||
invisible for the whole session. Joining with a short bounded timeout
|
||||
before the first agent build lets already-spawning fast servers land
|
||||
without re-introducing the startup hang: a dead server simply isn't waited
|
||||
on beyond ``timeout``. No-op when no discovery thread was started.
|
||||
"""
|
||||
thread = _mcp_discovery_thread
|
||||
if thread is None or not thread.is_alive():
|
||||
return
|
||||
thread.join(timeout=timeout)
|
||||
|
||||
|
||||
def main():
|
||||
_install_sidecar_publisher()
|
||||
|
||||
# MCP tool discovery — runs in a background daemon thread so a slow or
|
||||
# unreachable MCP server can't freeze TUI startup. Previously this ran
|
||||
# inline before ``gateway.ready``, which meant any configured-but-down
|
||||
# server stalled the whole shell on "summoning hermes…" for the full
|
||||
# connect-retry backoff (e.g. a dead stdio/http server burns 1+2+4s of
|
||||
# retries → ~7s of dead air before the composer appears). Discovery is
|
||||
# idempotent and registers tools into the shared registry as servers
|
||||
# connect. The agent isn't built until the first prompt, at which point
|
||||
# ``_make_agent`` briefly joins this thread (``wait_for_mcp_discovery``,
|
||||
# bounded) so already-spawning fast servers land in the tool snapshot —
|
||||
# a dead server is simply not waited on past the bound. ``/reload-mcp``
|
||||
# rebuilds the snapshot for servers that connect later in the session.
|
||||
#
|
||||
# Cold-start guard: importing ``tools.mcp_tool`` transitively pulls the
|
||||
# full MCP SDK (mcp, pydantic, httpx, jsonschema, starlette parsers —
|
||||
# ~200ms on macOS). The overwhelming majority of users have no
|
||||
# ``mcp_servers`` configured, in which case every byte of that import is
|
||||
# wasted. Check the config first (cheap) and only spawn the discovery
|
||||
# thread when there's actually MCP work to do, so the import cost stays
|
||||
# off the path entirely for the common case.
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
_mcp_servers = (read_raw_config() or {}).get("mcp_servers")
|
||||
_has_mcp_servers = isinstance(_mcp_servers, dict) and len(_mcp_servers) > 0
|
||||
except Exception:
|
||||
# Be conservative: if we can't decide, fall back to attempting
|
||||
# discovery (still backgrounded, so it can't block startup).
|
||||
_has_mcp_servers = True
|
||||
if _has_mcp_servers:
|
||||
def _discover_mcp_background() -> None:
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Background MCP tool discovery failed", exc_info=True
|
||||
)
|
||||
|
||||
import threading as _mcp_threading
|
||||
_mcp_thread = _mcp_threading.Thread(
|
||||
target=_discover_mcp_background,
|
||||
name="tui-mcp-discovery",
|
||||
daemon=True,
|
||||
)
|
||||
_mcp_thread.start()
|
||||
# Publish the handle so the first agent build can briefly wait for
|
||||
# already-spawning fast servers to land (see wait_for_mcp_discovery).
|
||||
global _mcp_discovery_thread
|
||||
_mcp_discovery_thread = _mcp_thread
|
||||
|
||||
if not write_json({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "event",
|
||||
"params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}},
|
||||
}):
|
||||
_log_exit("startup write failed (broken stdout pipe before first event)")
|
||||
sys.exit(0)
|
||||
|
||||
for raw in sys.stdin:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
if not write_json({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}):
|
||||
_log_exit("parse-error-response write failed (broken stdout pipe)")
|
||||
sys.exit(0)
|
||||
continue
|
||||
|
||||
method = req.get("method") if isinstance(req, dict) else None
|
||||
resp = dispatch(req)
|
||||
if resp is not None:
|
||||
if not write_json(resp):
|
||||
_log_exit(f"response write failed for method={method!r} (broken stdout pipe)")
|
||||
sys.exit(0)
|
||||
|
||||
_log_exit("stdin EOF (TUI closed the command pipe)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Best-effort WebSocket publisher transport for the PTY-side gateway.
|
||||
|
||||
The dashboard's `/api/pty` spawns `hermes --tui` as a child process, which
|
||||
spawns its own ``tui_gateway.entry``. Tool/reasoning/status events fire on
|
||||
*that* gateway's transport — three processes removed from the dashboard
|
||||
server itself. To surface them in the dashboard sidebar (`/api/events`),
|
||||
the PTY-side gateway opens a back-WS to the dashboard at startup and
|
||||
mirrors every emit through this transport.
|
||||
|
||||
Wire protocol: newline-framed JSON dicts (the same shape the dispatcher
|
||||
already passes to ``write``). No JSON-RPC envelope here — the dashboard's
|
||||
``/api/pub`` endpoint just rebroadcasts the bytes verbatim to subscribers.
|
||||
|
||||
Failure mode: silent. The agent loop must never block waiting for the
|
||||
sidecar to drain. A dead WS short-circuits all subsequent writes.
|
||||
Actual ``send`` calls run on a daemon thread so the TeeTransport's
|
||||
``write`` returns after enqueueing (best-effort; drop when the queue is full).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from websockets.sync.client import connect as ws_connect
|
||||
except ImportError: # pragma: no cover - websockets is a required install path
|
||||
ws_connect = None # type: ignore[assignment]
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_DRAIN_STOP = object()
|
||||
|
||||
_QUEUE_MAX = 256
|
||||
|
||||
|
||||
class WsPublisherTransport:
|
||||
__slots__ = ("_url", "_lock", "_ws", "_dead", "_q", "_worker")
|
||||
|
||||
def __init__(self, url: str, *, connect_timeout: float = 2.0) -> None:
|
||||
self._url = url
|
||||
self._lock = threading.Lock()
|
||||
self._ws: Optional[object] = None
|
||||
self._dead = False
|
||||
self._q: queue.Queue[object] = queue.Queue(maxsize=_QUEUE_MAX)
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
|
||||
if ws_connect is None:
|
||||
self._dead = True
|
||||
|
||||
return
|
||||
|
||||
try:
|
||||
self._ws = ws_connect(url, open_timeout=connect_timeout, max_size=None)
|
||||
except Exception as exc:
|
||||
_log.debug("event publisher connect failed: %s", exc)
|
||||
self._dead = True
|
||||
self._ws = None
|
||||
|
||||
return
|
||||
|
||||
self._worker = threading.Thread(
|
||||
target=self._drain,
|
||||
name="hermes-ws-pub",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker.start()
|
||||
|
||||
def _drain(self) -> None:
|
||||
while True:
|
||||
item = self._q.get()
|
||||
if item is _DRAIN_STOP:
|
||||
return
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
if self._ws is None:
|
||||
continue
|
||||
try:
|
||||
with self._lock:
|
||||
if self._ws is not None:
|
||||
self._ws.send(item) # type: ignore[union-attr]
|
||||
except Exception as exc:
|
||||
_log.debug("event publisher write failed: %s", exc)
|
||||
self._dead = True
|
||||
self._ws = None
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
if self._dead or self._ws is None or self._worker is None:
|
||||
return False
|
||||
|
||||
line = json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
self._q.put_nowait(line)
|
||||
|
||||
return True
|
||||
except queue.Full:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
self._dead = True
|
||||
w = self._worker
|
||||
if w is not None and w.is_alive():
|
||||
try:
|
||||
self._q.put_nowait(_DRAIN_STOP)
|
||||
except queue.Full:
|
||||
# Best-effort: if the queue is wedged, the daemon thread
|
||||
# will be torn down with the process.
|
||||
pass
|
||||
w.join(timeout=3.0)
|
||||
self._worker = None
|
||||
|
||||
if self._ws is None:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
if self._ws is not None:
|
||||
self._ws.close() # type: ignore[union-attr]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._ws = None
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Rendering bridge — routes TUI content through Python-side renderers.
|
||||
|
||||
When agent.rich_output exists, its functions are used. When it doesn't,
|
||||
everything returns None and the TUI falls back to its own markdown.tsx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def render_message(text: str, cols: int = 80) -> str | None:
|
||||
try:
|
||||
from agent.rich_output import format_response
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return format_response(text, cols=cols)
|
||||
except TypeError:
|
||||
return format_response(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def render_diff(text: str, cols: int = 80) -> str | None:
|
||||
try:
|
||||
from agent.rich_output import render_diff as _rd
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return _rd(text, cols=cols)
|
||||
except TypeError:
|
||||
return _rd(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def make_stream_renderer(cols: int = 80):
|
||||
try:
|
||||
from agent.rich_output import StreamingRenderer
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return StreamingRenderer(cols=cols)
|
||||
except TypeError:
|
||||
return StreamingRenderer()
|
||||
except Exception:
|
||||
return None
|
||||
+10305
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
"""Persistent slash-command worker — one HermesCLI per TUI session.
|
||||
|
||||
Protocol: reads JSON lines from stdin {id, command}, writes {id, ok, output|error} to stdout.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
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()
|
||||
if not cmd:
|
||||
return ""
|
||||
if not cmd.startswith("/"):
|
||||
cmd = f"/{cmd}"
|
||||
|
||||
buf = io.StringIO()
|
||||
|
||||
# Rich Console captures its file handle at construction time, so
|
||||
# contextlib.redirect_stdout won't affect it. Swap the console's
|
||||
# underlying file to our buffer so self.console.print() is captured.
|
||||
cli.console = Console(file=buf, force_terminal=True, width=120)
|
||||
|
||||
old = getattr(cli_mod, "_cprint", None)
|
||||
if old is not None:
|
||||
cli_mod._cprint = lambda text: print(text)
|
||||
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
||||
cli.process_command(cmd)
|
||||
finally:
|
||||
if old is not None:
|
||||
cli_mod._cprint = old
|
||||
|
||||
return buf.getvalue().rstrip()
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(add_help=False)
|
||||
p.add_argument("--session-key", required=True)
|
||||
p.add_argument("--model", default="")
|
||||
args = p.parse_args()
|
||||
|
||||
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)
|
||||
|
||||
for raw in sys.stdin:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
_in_flight.set()
|
||||
rid = None
|
||||
try:
|
||||
req = json.loads(line)
|
||||
rid = req.get("id")
|
||||
out = _run(cli, req.get("command", ""))
|
||||
sys.stdout.write(json.dumps({"id": rid, "ok": True, "output": out}) + "\n")
|
||||
sys.stdout.flush()
|
||||
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__":
|
||||
main()
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Transport abstraction for the tui_gateway JSON-RPC server.
|
||||
|
||||
Historically the gateway wrote every JSON frame directly to real stdout. This
|
||||
module decouples the I/O sink from the handler logic so the same dispatcher
|
||||
can be driven over stdio (``tui_gateway.entry``) or WebSocket
|
||||
(``tui_gateway.ws``) without duplicating code.
|
||||
|
||||
A :class:`Transport` is anything that can accept a JSON-serialisable dict and
|
||||
forward it to its peer. The active transport for the current request is
|
||||
tracked in a :class:`contextvars.ContextVar` so handlers — including those
|
||||
dispatched onto the worker pool — route their writes to the right peer.
|
||||
|
||||
Backward compatibility
|
||||
----------------------
|
||||
``tui_gateway.server.write_json`` still works without any transport bound.
|
||||
When nothing is on the contextvar and no session-level transport is found,
|
||||
it falls back to the module-level :class:`StdioTransport`, which wraps the
|
||||
original ``_real_stdout`` + ``_stdout_lock`` pair. Tests that monkey-patch
|
||||
``server._real_stdout`` continue to work because the stdio transport resolves
|
||||
the stream lazily through a callback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Callable, Optional, Protocol, runtime_checkable
|
||||
|
||||
# Errno values that mean "the peer is gone" rather than "the host has a
|
||||
# real I/O problem". Anything outside this set re-raises so it surfaces
|
||||
# in the crash log instead of looking like a clean disconnect.
|
||||
_PEER_GONE_ERRNOS = frozenset({
|
||||
errno.EPIPE, # write to closed pipe (POSIX)
|
||||
errno.ECONNRESET, # peer reset the connection
|
||||
errno.EBADF, # fd closed under us
|
||||
errno.ESHUTDOWN, # transport endpoint shut down
|
||||
getattr(errno, "WSAECONNRESET", -1), # win32 mapping (no-op on POSIX)
|
||||
getattr(errno, "WSAESHUTDOWN", -1),
|
||||
} - {-1})
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optional knob: when true, StdioTransport does not call ``stream.flush``
|
||||
# after writing. Use this on environments where a half-closed pipe (TUI
|
||||
# Node parent quit while the gateway is still emitting events) makes
|
||||
# flush block long enough to starve the rest of the worker pool.
|
||||
#
|
||||
# IMPORTANT: Python text stdout is fully buffered when attached to a
|
||||
# pipe (the TUI case), so this knob ONLY makes sense when the gateway
|
||||
# is launched with ``-u`` or ``PYTHONUNBUFFERED=1``. Without one of
|
||||
# those, JSON-RPC frames will accumulate in the buffer and the TUI
|
||||
# will hang waiting for ``gateway.ready``. Default stays off so the
|
||||
# existing flush-after-write behaviour is unchanged.
|
||||
_DISABLE_FLUSH = (os.environ.get("HERMES_TUI_GATEWAY_NO_FLUSH", "") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Transport(Protocol):
|
||||
"""Minimal interface every transport implements."""
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
"""Emit one JSON frame. Return ``False`` when the peer is gone."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release any resources owned by this transport."""
|
||||
|
||||
|
||||
_current_transport: contextvars.ContextVar[Optional[Transport]] = (
|
||||
contextvars.ContextVar(
|
||||
"hermes_gateway_transport",
|
||||
default=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def current_transport() -> Optional[Transport]:
|
||||
"""Return the transport bound for the current request, if any."""
|
||||
return _current_transport.get()
|
||||
|
||||
|
||||
def bind_transport(transport: Optional[Transport]):
|
||||
"""Bind *transport* for the current context. Returns a token for :func:`reset_transport`."""
|
||||
return _current_transport.set(transport)
|
||||
|
||||
|
||||
def reset_transport(token) -> None:
|
||||
"""Restore the transport binding captured by :func:`bind_transport`."""
|
||||
_current_transport.reset(token)
|
||||
|
||||
|
||||
class StdioTransport:
|
||||
"""Writes JSON frames to a stream (usually ``sys.stdout``).
|
||||
|
||||
The stream is resolved via a callable so runtime monkey-patches of the
|
||||
underlying stream continue to work — this preserves the behaviour the
|
||||
existing test suite relies on (``monkeypatch.setattr(server, "_real_stdout", ...)``).
|
||||
"""
|
||||
|
||||
__slots__ = ("_stream_getter", "_lock")
|
||||
|
||||
def __init__(self, stream_getter: Callable[[], Any], lock: threading.Lock) -> None:
|
||||
self._stream_getter = stream_getter
|
||||
self._lock = lock
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
"""Return ``True`` on success, ``False`` ONLY when the peer is gone.
|
||||
|
||||
Returning ``False`` is the dispatcher's "broken stdout pipe" signal
|
||||
— ``entry.py`` calls ``sys.exit(0)`` when ``write_json`` reports
|
||||
``False``. So programming errors (non-JSON-safe payloads, encoding
|
||||
misconfig, unexpected ValueErrors, host I/O bugs like ENOSPC) MUST
|
||||
NOT return ``False``, otherwise a real bug looks like a clean
|
||||
disconnect and is harder to diagnose. Those re-raise so the
|
||||
existing crash-log infrastructure records the traceback.
|
||||
|
||||
Peer-gone branches:
|
||||
* ``BrokenPipeError``
|
||||
* ``ValueError("...closed file...")``
|
||||
* ``OSError`` whose errno is in :data:`_PEER_GONE_ERRNOS`
|
||||
(EPIPE / ECONNRESET / EBADF / ESHUTDOWN; plus WSA mappings
|
||||
on Windows). Other OSError errnos (ENOSPC, EACCES, ...) are
|
||||
real host problems and re-raise.
|
||||
"""
|
||||
# Serialization is OUTSIDE the lock so a large payload can't
|
||||
# block other threads emitting their own frames. A non-JSON-safe
|
||||
# payload is a programming error: re-raise so the crash log
|
||||
# captures it instead of silently exiting via the False path.
|
||||
line = json.dumps(obj, ensure_ascii=False) + "\n"
|
||||
|
||||
with self._lock:
|
||||
stream = self._stream_getter()
|
||||
try:
|
||||
stream.write(line)
|
||||
except BrokenPipeError:
|
||||
return False
|
||||
except ValueError as e:
|
||||
# ValueError("I/O operation on closed file") is the
|
||||
# ONLY ValueError that means "peer gone". Anything
|
||||
# else — including UnicodeEncodeError, which is a
|
||||
# ValueError subclass for misconfigured locales —
|
||||
# is a real bug; re-raise so it surfaces in the crash log.
|
||||
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
|
||||
raise
|
||||
return False
|
||||
except OSError as e:
|
||||
if e.errno not in _PEER_GONE_ERRNOS:
|
||||
raise
|
||||
logger.debug("StdioTransport write peer gone: %s", e)
|
||||
return False
|
||||
|
||||
# A flush that *raises* with a peer-gone errno means the
|
||||
# dispatcher should exit cleanly. A flush that *hangs* on
|
||||
# a half-closed pipe holds the lock until it returns — see
|
||||
# ``_DISABLE_FLUSH`` for the "skip flush entirely" escape
|
||||
# hatch.
|
||||
if not _DISABLE_FLUSH:
|
||||
try:
|
||||
stream.flush()
|
||||
except BrokenPipeError:
|
||||
return False
|
||||
except ValueError as e:
|
||||
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
|
||||
raise
|
||||
return False
|
||||
except OSError as e:
|
||||
if e.errno not in _PEER_GONE_ERRNOS:
|
||||
raise
|
||||
logger.debug("StdioTransport flush peer gone: %s", e)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class TeeTransport:
|
||||
"""Mirrors writes to one primary plus N best-effort secondaries.
|
||||
|
||||
The primary's return value (and exceptions) determine the result —
|
||||
secondaries swallow failures so a wedged sidecar never stalls the
|
||||
main IO path. Used by the PTY child so every dispatcher emit lands
|
||||
on stdio (Ink) AND on a back-WS feeding the dashboard sidebar.
|
||||
"""
|
||||
|
||||
__slots__ = ("_primary", "_secondaries")
|
||||
|
||||
def __init__(self, primary: "Transport", *secondaries: "Transport") -> None:
|
||||
self._primary = primary
|
||||
self._secondaries = secondaries
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
# Primary first so a slow sidecar (WS publisher) never delays Ink/stdio.
|
||||
ok = self._primary.write(obj)
|
||||
for sec in self._secondaries:
|
||||
try:
|
||||
sec.write(obj)
|
||||
except Exception:
|
||||
pass
|
||||
return ok
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._primary.close()
|
||||
finally:
|
||||
for sec in self._secondaries:
|
||||
try:
|
||||
sec.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,340 @@
|
||||
"""WebSocket transport for the tui_gateway JSON-RPC server.
|
||||
|
||||
Reuses :func:`tui_gateway.server.dispatch` verbatim so every RPC method, every
|
||||
slash command, every approval/clarify/sudo flow, and every agent event flows
|
||||
through the same handlers whether the client is Ink over stdio or an iOS /
|
||||
web client over WebSocket.
|
||||
|
||||
Wire protocol
|
||||
-------------
|
||||
Identical to stdio: newline-delimited JSON-RPC in both directions. The server
|
||||
emits a ``gateway.ready`` event immediately after connection accept, then
|
||||
echoes responses/events for inbound requests. No framing differences.
|
||||
|
||||
Mounting
|
||||
--------
|
||||
from fastapi import WebSocket
|
||||
from tui_gateway.ws import handle_ws
|
||||
|
||||
@app.websocket("/api/ws")
|
||||
async def ws(ws: WebSocket):
|
||||
await handle_ws(ws)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
from tui_gateway import server
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Max seconds a pool-dispatched handler will block waiting for the event loop
|
||||
# to flush a WS frame before we mark the transport dead. Protects handler
|
||||
# threads from a wedged socket.
|
||||
_WS_WRITE_TIMEOUT_S = 10.0
|
||||
_WS_LOG_PAYLOAD_PREVIEW = 240
|
||||
|
||||
# Keep starlette optional at import time; handle_ws uses the real class when
|
||||
# it's available and falls back to a generic Exception sentinel otherwise.
|
||||
try:
|
||||
from starlette.websockets import WebSocketDisconnect as _WebSocketDisconnect
|
||||
except ImportError: # pragma: no cover - starlette is a required install path
|
||||
_WebSocketDisconnect = Exception # type: ignore[assignment]
|
||||
|
||||
|
||||
class WSTransport:
|
||||
"""Per-connection WS transport.
|
||||
|
||||
``write`` is safe to call from any thread *other than* the event loop
|
||||
thread that owns the socket. Pool workers (the only real caller) run in
|
||||
their own threads, so marshalling onto the loop via
|
||||
:func:`asyncio.run_coroutine_threadsafe` + ``future.result()`` is correct
|
||||
and deadlock-free there.
|
||||
|
||||
When called from the loop thread itself (e.g. by ``handle_ws`` for an
|
||||
inline response) the same call would deadlock: we'd schedule work onto
|
||||
the loop we're currently blocking. We detect that case and fire-and-
|
||||
forget instead. Callers that need to know when the bytes are on the wire
|
||||
should use :meth:`write_async` from the loop thread.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ws: Any,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
*,
|
||||
peer: str = "unknown",
|
||||
) -> None:
|
||||
self._ws = ws
|
||||
self._loop = loop
|
||||
self._peer = peer
|
||||
self._closed = False
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
if self._closed:
|
||||
return False
|
||||
|
||||
line = json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
on_loop = asyncio.get_running_loop() is self._loop
|
||||
except RuntimeError:
|
||||
on_loop = False
|
||||
|
||||
if on_loop:
|
||||
# Fire-and-forget — don't block the loop waiting on itself.
|
||||
self._loop.create_task(self._safe_send(line))
|
||||
return True
|
||||
|
||||
try:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
fut = safe_schedule_threadsafe(self._safe_send(line), self._loop)
|
||||
if fut is None:
|
||||
self._closed = True
|
||||
return False
|
||||
fut.result(timeout=_WS_WRITE_TIMEOUT_S)
|
||||
return not self._closed
|
||||
except concurrent.futures.TimeoutError: # builtin TimeoutError on 3.11+
|
||||
# The event loop is stalled (GIL-heavy agent turn, delegation
|
||||
# running N children), NOT the socket dead. The send coroutine is
|
||||
# already scheduled and will flush once the loop breathes — latching
|
||||
# _closed here permanently silenced live windows after one slow
|
||||
# write (the "subagent window shows zero streaming" bug). Unblock
|
||||
# the worker thread and keep the transport alive; _safe_send latches
|
||||
# on a real socket error when the frame actually fails.
|
||||
_log.warning(
|
||||
"ws write slow (loop stalled >%ss) peer=%s — frame left in flight",
|
||||
_WS_WRITE_TIMEOUT_S, self._peer,
|
||||
)
|
||||
return not self._closed
|
||||
except Exception as exc:
|
||||
self._closed = True
|
||||
_log.warning(
|
||||
"ws write failed peer=%s error_type=%s error=%s",
|
||||
self._peer, type(exc).__name__, exc,
|
||||
)
|
||||
return False
|
||||
|
||||
async def write_async(self, obj: dict) -> bool:
|
||||
"""Send from the owning event loop. Awaits until the frame is on the wire."""
|
||||
if self._closed:
|
||||
return False
|
||||
await self._safe_send(json.dumps(obj, ensure_ascii=False))
|
||||
return not self._closed
|
||||
|
||||
async def _safe_send(self, line: str) -> None:
|
||||
try:
|
||||
await self._ws.send_text(line)
|
||||
except Exception as exc:
|
||||
self._closed = True
|
||||
_log.warning(
|
||||
"ws send failed peer=%s error_type=%s error=%s",
|
||||
self._peer, type(exc).__name__, exc,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
|
||||
def _ws_peer_label(ws: Any) -> str:
|
||||
"""Return ``host:port`` when available, else a stable placeholder."""
|
||||
client = getattr(ws, "client", None)
|
||||
if client is None:
|
||||
return "unknown"
|
||||
host = getattr(client, "host", None) or "unknown"
|
||||
port = getattr(client, "port", None)
|
||||
return f"{host}:{port}" if port is not None else host
|
||||
|
||||
|
||||
def _disable_nagle(ws: Any) -> None:
|
||||
"""Disable Nagle so streamed JSON-RPC frames go out individually.
|
||||
|
||||
Without it the kernel coalesces the small per-token frames, so a burst after
|
||||
the model's think-pause lands on the client in one tick and no client-side
|
||||
smoothing can recover the cadence. GUI/WS only; chat platforms don't hit
|
||||
this path. Best-effort — skip silently if the socket isn't reachable.
|
||||
"""
|
||||
try:
|
||||
scope = getattr(ws, "scope", None) or {}
|
||||
transport = (scope.get("extensions") or {}).get("transport") or getattr(ws, "transport", None)
|
||||
sock = transport.get_extra_info("socket") if transport is not None else None
|
||||
if sock is not None:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
except Exception as exc: # pragma: no cover - best-effort tuning
|
||||
_log.debug("ws TCP_NODELAY skip: %s", exc)
|
||||
|
||||
|
||||
async def handle_ws(ws: Any) -> None:
|
||||
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``."""
|
||||
peer = _ws_peer_label(ws)
|
||||
transport: WSTransport | None = None
|
||||
messages = 0
|
||||
parse_errors = 0
|
||||
dispatch_crashes = 0
|
||||
send_failures = 0
|
||||
disconnect_reason = "not_connected"
|
||||
|
||||
try:
|
||||
await ws.accept()
|
||||
disconnect_reason = "connected"
|
||||
# Push small streamed frames out immediately instead of letting Nagle
|
||||
# batch them — keeps the live token cadence intact for GUI clients.
|
||||
_disable_nagle(ws)
|
||||
_log.info("ws accepted peer=%s", peer)
|
||||
|
||||
transport = WSTransport(ws, asyncio.get_running_loop(), peer=peer)
|
||||
|
||||
ready_ok = await transport.write_async(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "event",
|
||||
"params": {
|
||||
"type": "gateway.ready",
|
||||
"payload": {"skin": server.resolve_skin()},
|
||||
},
|
||||
}
|
||||
)
|
||||
if not ready_ok:
|
||||
disconnect_reason = "ready_send_failed"
|
||||
send_failures += 1
|
||||
_log.error("ws ready frame send failed peer=%s", peer)
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
raw = await ws.receive_text()
|
||||
except _WebSocketDisconnect as exc:
|
||||
disconnect_reason = (
|
||||
"client_disconnect("
|
||||
f"code={getattr(exc, 'code', None)},"
|
||||
f"reason={getattr(exc, 'reason', None)})"
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
disconnect_reason = "receive_failed"
|
||||
_log.exception("ws receive failed peer=%s", peer)
|
||||
break
|
||||
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
messages += 1
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
parse_errors += 1
|
||||
_log.warning(
|
||||
"ws parse error peer=%s index=%d error=%s payload=%r",
|
||||
peer,
|
||||
messages,
|
||||
exc,
|
||||
line[:_WS_LOG_PAYLOAD_PREVIEW],
|
||||
)
|
||||
ok = await transport.write_async(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32700, "message": "parse error"},
|
||||
"id": None,
|
||||
}
|
||||
)
|
||||
if not ok:
|
||||
disconnect_reason = "send_failed_after_parse_error"
|
||||
send_failures += 1
|
||||
_log.warning("ws parse-error reply send failed peer=%s", peer)
|
||||
break
|
||||
continue
|
||||
|
||||
# dispatch() may schedule long handlers on the pool; it returns
|
||||
# None in that case and the worker writes the response itself via
|
||||
# the transport we pass in (a separate thread, so transport.write
|
||||
# is the safe path there). For inline handlers it returns the
|
||||
# response dict, which we write here from the loop.
|
||||
req_id = req.get("id") if isinstance(req, dict) else None
|
||||
req_method = req.get("method") if isinstance(req, dict) else None
|
||||
try:
|
||||
resp = await asyncio.to_thread(server.dispatch, req, transport)
|
||||
except Exception:
|
||||
dispatch_crashes += 1
|
||||
_log.exception(
|
||||
"ws dispatch crash peer=%s id=%s method=%s",
|
||||
peer,
|
||||
req_id,
|
||||
req_method,
|
||||
)
|
||||
ok = await transport.write_async(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32603, "message": "internal error"},
|
||||
"id": req_id if req_id is not None else None,
|
||||
}
|
||||
)
|
||||
if not ok:
|
||||
disconnect_reason = "send_failed_after_dispatch_crash"
|
||||
send_failures += 1
|
||||
_log.warning(
|
||||
"ws dispatch-crash reply send failed peer=%s id=%s method=%s",
|
||||
peer,
|
||||
req_id,
|
||||
req_method,
|
||||
)
|
||||
break
|
||||
continue
|
||||
if resp is not None and not await transport.write_async(resp):
|
||||
disconnect_reason = "send_failed_after_response"
|
||||
send_failures += 1
|
||||
_log.warning(
|
||||
"ws response send failed peer=%s id=%s method=%s",
|
||||
peer,
|
||||
req_id,
|
||||
req_method,
|
||||
)
|
||||
break
|
||||
finally:
|
||||
reaped_sessions = 0
|
||||
detached_sessions = 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.
|
||||
#
|
||||
# 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)
|
||||
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",
|
||||
peer,
|
||||
disconnect_reason,
|
||||
messages,
|
||||
parse_errors,
|
||||
dispatch_crashes,
|
||||
send_failures,
|
||||
reaped_sessions,
|
||||
detached_sessions,
|
||||
)
|
||||
Reference in New Issue
Block a user