hermes-agent/tui_gateway/entry.py
brein 8b11249755 fix(tui-gateway): guard entry signal installs to main thread
Importing tui_gateway.entry from a worker thread raised
'ValueError: signal only works in main thread of the main interpreter'
because signal.signal() was called unconditionally at import time.

On the Desktop/WebSocket path, server._build() runs in a daemon thread and
does 'from tui_gateway.entry import ensure_mcp_discovery_started' as the
first import of entry (entry.main() is never run there), which crashed and
aborted MCP discovery startup — every session then lost its MCP servers
(e.g. Dart-mcp) with ClosedResourceError.

signal handlers are process-global, so installing them only when the module
is first imported in the main thread is sufficient; importing from a worker
thread becomes a safe no-op.

Fixes #72667
2026-07-28 00:05:36 +05:30

484 lines
20 KiB
Python

import os
import sys
# Stop a ``utils/`` (or ``proxy/``, ``ui/``) package in the launch directory
# from shadowing Hermes's own top-level modules. ``hermes_bootstrap`` lives at
# the repo root next to this package, so importing it is safe before the guard
# runs (its name won't collide with a user package), and it owns the canonical
# path-hardening logic shared with the other entry points.
import hermes_bootstrap
hermes_bootstrap.harden_import_path()
import json
import logging
import signal
import threading
import time
import traceback
from tui_gateway._stdin_recovery import handle_spurious_eof
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
# ensure_mcp_discovery_started). 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). Stays None when discovery is delegated to the
# shared owner in hermes_cli.mcp_startup — the wait/in-flight/join helpers
# below consult both owners.
_mcp_discovery_thread = None
# True once ensure_mcp_discovery_started decided this process has MCP servers
# configured and spawned discovery through the shared owner. Lets
# wait_for_mcp_discovery re-invoke the (idempotent) spawn on later agent
# builds so the retry-after-zero-connected allowance in
# hermes_cli.mcp_startup.start_background_mcp_discovery can actually fire —
# without this, the single spawn is the only call and a first run that
# connected nothing latches the process MCP-less. Kept as a flag (rather than
# re-probing config) so non-MCP sessions never pay the tools.mcp_tool import
# on the per-agent-build wait path.
_mcp_discovery_enabled = False
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()
# ── Flush sessions before exit ───────────────────────────────────
# The atexit handler (_shutdown_sessions) is registered in
# tui_gateway/server.py, but a worker thread holding the GIL or
# _stdout_lock can block atexit from completing within the grace
# window. Explicitly finalize sessions here so that unpersisted
# messages reach state.db before the hard-exit timer fires.
try:
from tui_gateway.server import _shutdown_sessions
_shutdown_sessions()
except Exception:
pass
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.
#
# signal.signal() is only legal in the MAIN thread. On the Desktop/WebSocket
# agent-build path, server._build() runs in a daemon thread and does
# ``from tui_gateway.entry import ensure_mcp_discovery_started`` as the first
# import of entry (entry.main() is never run there), which used to raise
# "ValueError: signal only works in main thread of the main interpreter" and
# abort MCP discovery startup. Install each handler only when we're in the
# main thread: handlers are process-global, so a main-thread import anywhere
# in the process still installs them for everyone, and an off-thread import
# (Desktop build path) simply no-ops instead of crashing the import. This
# preserves the original SIG_IGN/SIG_DFL behavior on the classic TUI/serve
# path while fixing the off-thread import crash.
def _install_signal(signame, handler):
"""Install a signal handler if legal in this thread.
signal.signal() raises ValueError outside the main thread; skip silently
there so a worker-thread import of this module (Desktop build path) does
not abort. On any main-thread import the handler is installed as before.
"""
if threading.current_thread() is not threading.main_thread():
return
sig = getattr(signal, signame, None)
if sig is None:
return # Windows: SIGPIPE/SIGHUP absent
try:
signal.signal(sig, handler)
except (ValueError, OSError, RuntimeError):
# Not in the main thread despite the check, or handler rejected.
# Skip rather than crash the import (see above).
pass
_install_signal("SIGPIPE", signal.SIG_IGN)
_install_signal("SIGTERM", _log_signal)
if hasattr(signal, "SIGHUP"):
_install_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.
_install_signal("SIGBREAK", _log_signal)
_install_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 | None" = None) -> None:
"""Block until background MCP discovery finishes, up to the resolved bound.
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 bounded timeout before the
first agent build lets already-spawning servers land without re-introducing
the startup hang: ``thread.join(timeout)`` returns the instant discovery
completes (so fast/no-MCP startups pay ~0s), and a dead server is simply not
waited on beyond the bound. No-op when no discovery thread was started.
The bound comes from ``mcp_discovery_timeout`` in config (shared with the
CLI path via ``hermes_cli.mcp_startup``); ``timeout`` overrides it.
"""
thread = _mcp_discovery_thread
if thread is not None and thread.is_alive():
try:
from hermes_cli.mcp_startup import _resolve_discovery_timeout
bound = _resolve_discovery_timeout(timeout)
except Exception:
bound = timeout if timeout is not None else 0.75
thread.join(timeout=bound)
return
# Discovery is spawned via the shared owner (ensure_mcp_discovery_started
# → hermes_cli.mcp_startup); wait on it so the first agent build still
# catches fast servers. Re-invoke the idempotent spawn first: if the
# previous run finished with zero connected servers,
# start_background_mcp_discovery's retry-after-zero-connected allowance
# kicks off a fresh discovery run here instead of leaving the process
# latched MCP-less for the session. In multi-profile processes this
# retry runs under the CALLER's profile context (agent build binds the
# session profile's HERMES_HOME first), so a launch profile with no
# mcp_servers no longer starves selected profiles of discovery (#67605).
# Gated on _mcp_discovery_enabled so non-MCP sessions never pay the
# tools.mcp_tool import on the per-agent-build wait path.
if not _mcp_discovery_enabled:
return
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(
logger=logger, thread_name="tui-mcp-discovery"
)
except Exception:
logger.debug(
"TUI MCP discovery retry-spawn failed", exc_info=True
)
try:
from hermes_cli.mcp_startup import (
wait_for_mcp_discovery as _startup_wait,
)
_startup_wait(timeout)
except Exception:
pass
def mcp_discovery_in_flight() -> bool:
"""Return True if ANY background MCP discovery thread is still running.
Used by the agent-build path to decide whether to schedule a late tool
snapshot refresh: if discovery didn't land within the bounded
``wait_for_mcp_discovery`` join, the agent was built without those tools
and the banner/tool count will be stale until they arrive.
There are two independent discovery-thread owners by surface: the stdio
``hermes --tui`` path spawns ITS thread here (``_mcp_discovery_thread``),
while the desktop app + dashboard WebSocket sidecar (``tui_gateway/ws.py``)
and ``hermes dashboard`` spawn theirs via
``hermes_cli.mcp_startup.start_background_mcp_discovery``. The late-refresh
scheduler imports this function regardless of surface, so it MUST consult
both — checking only the entry thread left the desktop/dashboard surfaces
with no late refresh, so a slow MCP server's tools never surfaced for the
whole session (#51587).
"""
thread = _mcp_discovery_thread
if thread is not None and thread.is_alive():
return True
try:
from hermes_cli.mcp_startup import (
mcp_discovery_in_flight as _startup_in_flight,
)
return _startup_in_flight()
except Exception:
return False
def join_mcp_discovery(timeout: float | None = None) -> bool:
"""Block until background MCP discovery finishes, up to ``timeout`` seconds.
Returns True if discovery has completed (both thread owners absent or no
longer alive), False if either is still running after the timeout. Unlike
``wait_for_mcp_discovery`` this accepts an unbounded/long wait and reports
the outcome, for the off-critical-path late-refresh waiter.
Joins both discovery-thread owners (see ``mcp_discovery_in_flight``): the
entry thread first, then the ``hermes_cli.mcp_startup`` thread used by the
desktop/dashboard surfaces. ``timeout`` bounds EACH join, mirroring the
pre-#51587 single-owner behavior for the entry thread.
"""
entry_done = True
thread = _mcp_discovery_thread
if thread is not None:
thread.join(timeout=timeout)
entry_done = not thread.is_alive()
try:
from hermes_cli.mcp_startup import join_mcp_discovery as _startup_join
startup_done = _startup_join(timeout=timeout)
except Exception:
startup_done = True
return entry_done and startup_done
# Spurious stdin-EOF recovery tracker (shared open-file-description O_NONBLOCK flip).
_recovery_times: list[float] = []
def _has_configured_mcp_servers() -> bool:
"""Return whether startup should attempt MCP discovery.
Keep this cheap so non-MCP users do not pay the MCP SDK import cost.
"""
try:
from hermes_cli.config import read_raw_config
mcp_servers = (read_raw_config() or {}).get("mcp_servers")
return isinstance(mcp_servers, dict) and len(mcp_servers) > 0
except Exception:
# Be conservative: if we can't decide, fall back to attempting
# discovery. The caller starts it in the background.
return True
def ensure_mcp_discovery_started() -> None:
"""Start background MCP discovery for the current profile context, once.
``main()`` calls this for the stdio/TUI path. WebSocket/Desktop
entrypoints can accept sessions without running ``main()``, so the
agent-build path (``server._start_agent_build``) also calls it AFTER
binding the session profile's HERMES_HOME override — the shared owner in
``hermes_cli.mcp_startup`` captures the caller's context-local override
and propagates it into the discovery thread, so discovery reads the
SELECTED profile's ``mcp_servers``, not the launch profile's (#67605).
Delegating to the shared owner (instead of a hand-rolled thread) keeps
the process-wide start lock, the retry-after-zero-connected allowance,
and interactive-OAuth suppression.
Known limitation: MCP tool registration is process-global, so in a
multi-profile process the FIRST profile that builds an agent wins the
discovery slot. Full per-profile MCP registries are tracked in #67605.
"""
global _mcp_discovery_enabled
if not _has_configured_mcp_servers():
return
_mcp_discovery_enabled = True
try:
from hermes_cli.mcp_startup import start_background_mcp_discovery
start_background_mcp_discovery(
logger=logger, thread_name="tui-mcp-discovery"
)
except Exception:
logger.warning(
"Background MCP tool discovery failed to start", exc_info=True
)
def main():
_install_sidecar_publisher()
# MCP tool discovery — backgrounded so a slow or unreachable MCP server
# can't freeze TUI startup (a dead stdio/http server burns 1+2+4s of
# connect retries → ~7s of dead air before the composer appears). The
# agent isn't built until the first prompt, at which point _make_agent
# briefly joins the discovery thread (wait_for_mcp_discovery, bounded) so
# already-spawning fast servers land in the tool snapshot. The config
# gate inside ensure_mcp_discovery_started keeps the ~200ms MCP SDK
# import cost entirely off the path for users with no mcp_servers.
ensure_mcp_discovery_started()
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)
# Live-apply skins Hermes activates mid-conversation.
server._ensure_skin_watcher()
while True:
raw = sys.stdin.readline()
if not raw:
# Stdin fell through — check if spurious (O_NONBLOCK flip by a
# child on the shared open file description) or genuine EOF.
if not handle_spurious_eof(_recovery_times, _log_exit):
break
continue
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)
if __name__ == "__main__":
main()