From fdd8d75ba06e949c46d8fbb4037a0130b353dc60 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:48:45 -0700 Subject: [PATCH] fix(gateway): make ws keepalive and orphan-reap grace config-driven (#79635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New dashboard.ws_ping_interval / dashboard.ws_ping_timeout defaults (20.0/20.0) in DEFAULT_CONFIG; hermes_cli/web_server.py reads them for non-loopback binds. Loopback keeps ws_ping=None (event-loop stalls must never kill a healthy local connection). - New dashboard.ws_orphan_reap_grace_s (20.0): tui_gateway/server.py's _WS_ORPHAN_REAP_GRACE_S now resolves from config via _resolve_ws_orphan_reap_grace(); the HERMES_TUI_WS_ORPHAN_REAP_GRACE_S env var is kept as an internal override for backward compat and wins when set. - tests/test_ws_keepalive_config.py: real load_config against a temp HERMES_HOME yaml — defaults, propagation, deep-merge, env override, invalid-value fallback. --- hermes_cli/config_defaults.py | 12 ++++ hermes_cli/web_server.py | 18 +++++- tests/test_ws_keepalive_config.py | 92 +++++++++++++++++++++++++++++++ tui_gateway/server.py | 32 ++++++++--- 4 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 tests/test_ws_keepalive_config.py diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 3038612cbb..6c6f11f744 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1571,6 +1571,18 @@ DEFAULT_CONFIG = { # Set this to True to re-enable the surfaces with the understanding # that the numbers are a local lower-bound estimate, not billing. "show_token_analytics": False, + # WebSocket keepalive for the dashboard/desktop web server (#79635). + # Applied to NON-loopback binds only: loopback always disables the + # protocol ping (see hermes_cli/web_server.py — an event-loop stall + # must never kill a healthy local connection). Values are seconds. + "ws_ping_interval": 20.0, + "ws_ping_timeout": 20.0, + # Grace window (seconds) before a WS-orphaned gateway session is + # interrupted/reaped after its client disconnects (#79635). The + # HERMES_TUI_WS_ORPHAN_REAP_GRACE_S env var remains an internal + # override for backward compatibility. 0 disables the reap + # (park forever). + "ws_orphan_reap_grace_s": 20.0, # OAuth gate configuration (engaged when ``--host`` is set and # ``--insecure`` is not). The bundled Nous Portal plugin reads # both keys at startup; they are the canonical surface for these diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 63222b9db0..244eaa830f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -19316,6 +19316,20 @@ def start_server( # ping at 20/20 to detect it promptly and stay under the tunnel's idle # window. _is_loopback = host in ("127.0.0.1", "localhost", "::1") + # Non-loopback ping cadence is config-driven (dashboard.ws_ping_interval / + # dashboard.ws_ping_timeout, #79635); the 20/20 defaults keep the + # Cloudflare-Tunnel-friendly behaviour when unset or invalid. + try: + _dash_cfg = load_config().get("dashboard") or {} + except Exception: + _dash_cfg = {} + + def _ws_ping_setting(key: str, default: float = 20.0) -> float: + try: + return float(_dash_cfg.get(key, default)) + except (TypeError, ValueError): + return default + config = uvicorn.Config( app, host=host, port=port, log_level="warning", # proxy_headers defaults to False so _ws_client_is_allowed sees @@ -19330,8 +19344,8 @@ def start_server( # disables the protocol ping (None) so an event-loop stall can never # trigger a false disconnect; a genuinely dead local client is still # reaped via the WebSocketDisconnect → disconnect/reap path. - ws_ping_interval=None if _is_loopback else 20.0, - ws_ping_timeout=None if _is_loopback else 20.0, + ws_ping_interval=None if _is_loopback else _ws_ping_setting("ws_ping_interval"), + ws_ping_timeout=None if _is_loopback else _ws_ping_setting("ws_ping_timeout"), ws_max_size=_DESKTOP_ATTACHMENT_WS_MAX_BYTES, ) server = uvicorn.Server(config) diff --git a/tests/test_ws_keepalive_config.py b/tests/test_ws_keepalive_config.py new file mode 100644 index 0000000000..8b16e1d927 --- /dev/null +++ b/tests/test_ws_keepalive_config.py @@ -0,0 +1,92 @@ +"""Config propagation tests for the WS keepalive + orphan-reap grace knobs (#79635).""" + +import textwrap + +import pytest + + +@pytest.fixture() +def _temp_home(tmp_path, monkeypatch): + home = tmp_path / "hermes-home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S", raising=False) + return home + + +def _write_config(home, body: str) -> None: + (home / "config.yaml").write_text(textwrap.dedent(body)) + + +def test_dashboard_ws_defaults_present(_temp_home): + from hermes_cli.config import load_config + + _write_config(_temp_home, "model:\n default: test-model\n") + cfg = load_config() + dash = cfg.get("dashboard") or {} + assert dash.get("ws_ping_interval") == 20.0 + assert dash.get("ws_ping_timeout") == 20.0 + assert dash.get("ws_orphan_reap_grace_s") == 20.0 + + +def test_dashboard_ws_values_propagate_from_yaml(_temp_home): + from hermes_cli.config import load_config + + _write_config( + _temp_home, + """ + dashboard: + ws_ping_interval: 45.5 + ws_ping_timeout: 10 + ws_orphan_reap_grace_s: 90 + """, + ) + cfg = load_config() + dash = cfg["dashboard"] + assert dash["ws_ping_interval"] == 45.5 + assert dash["ws_ping_timeout"] == 10 + assert dash["ws_orphan_reap_grace_s"] == 90 + # Deep-merge: sibling defaults survive a partial user section. + assert dash.get("theme") == "default" + + +def test_ws_orphan_reap_grace_reads_config(_temp_home): + from tui_gateway import server + + _write_config( + _temp_home, + """ + dashboard: + ws_orphan_reap_grace_s: 33 + """, + ) + assert server._resolve_ws_orphan_reap_grace() == 33.0 + + +def test_ws_orphan_reap_grace_env_var_overrides_config(_temp_home, monkeypatch): + from tui_gateway import server + + _write_config( + _temp_home, + """ + dashboard: + ws_orphan_reap_grace_s: 33 + """, + ) + monkeypatch.setenv("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S", "7") + assert server._resolve_ws_orphan_reap_grace() == 7.0 + + +def test_ws_orphan_reap_grace_invalid_values_fall_back(_temp_home, monkeypatch): + from tui_gateway import server + + _write_config( + _temp_home, + """ + dashboard: + ws_orphan_reap_grace_s: not-a-number + """, + ) + assert server._resolve_ws_orphan_reap_grace() == 20.0 + monkeypatch.setenv("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S", "-5") + assert server._resolve_ws_orphan_reap_grace() == 0.0 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 02f54bb103..04c58595ba 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -180,13 +180,31 @@ _SLASH_WORKER_TIMEOUT_S = max(5.0, _slash_timeout) # After this grace window, an orphaned WS session is interrupted if it is still # running, then reaped once the normal turn-finalization path settles. # Set to 0 to disable (park forever, pre-fix behaviour). -try: - _ws_orphan_reap_grace = float( - os.environ.get("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S") or "20" - ) -except (ValueError, TypeError): - _ws_orphan_reap_grace = 20.0 -_WS_ORPHAN_REAP_GRACE_S = max(0.0, _ws_orphan_reap_grace) +def _resolve_ws_orphan_reap_grace() -> float: + """Resolve the WS-orphan reap grace window (seconds). + + Config-driven via ``dashboard.ws_orphan_reap_grace_s`` (#79635); the + ``HERMES_TUI_WS_ORPHAN_REAP_GRACE_S`` env var is kept as an internal + override for backward compatibility and wins when set. + """ + raw = os.environ.get("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S") + if raw is None or not str(raw).strip(): + try: + from hermes_cli.config import load_config + + raw = (load_config().get("dashboard") or {}).get( + "ws_orphan_reap_grace_s" + ) + except Exception: + raw = None + try: + grace = float(raw) if raw is not None else 20.0 + except (ValueError, TypeError): + grace = 20.0 + return max(0.0, grace) + + +_WS_ORPHAN_REAP_GRACE_S = _resolve_ws_orphan_reap_grace() _WS_ORPHAN_INTERRUPT_REAP_POLL_S = 1.0 # Total budget for the interrupt-then-reap poll chain. If an interrupted turn # never settles (agent thread hung in a syscall, supervisor lost), each 1s poll