From dfd9b571830df4336e7b47f55defb1e26856f48e Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 9 Jul 2026 08:03:02 +0000 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20keep=20600s=20read-timeout=20de?= =?UTF-8?q?fault=20(gate=20finding=20=E2=80=94=20120s=20would=20kill=20leg?= =?UTF-8?q?it=20long=20silent=20tool=20calls)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ai-ag2026 --- api/gateway_chat.py | 16 ++++++++++------ tests/test_gateway_read_idle_timeout.py | 14 +++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/api/gateway_chat.py b/api/gateway_chat.py index e29020f4..cac555be 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -55,16 +55,20 @@ _GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"} # read timeout ends the turn (surfacing Stop if pressed). It replaces the old flat # 600s timeout, under which a half-open gateway (TCP open, zero bytes) pinned the # worker for the full 10 minutes and ignored Stop (cancel is only re-checked -# between SSE lines). 120s is generous for a slow first token / short silent -# server-side tool call while capping the dead-gateway pin far below 600s; no -# protocol heartbeat exists, so this is purely timeout-based. Deployments with -# long fully-silent tool calls can raise ``HERMES_WEBUI_GATEWAY_READ_TIMEOUT``. +# between SSE lines). We KEEP the 600s default budget: there is no Gateway +# protocol heartbeat guaranteeing sub-600s progress bytes, so a legitimately +# long/fully-silent server-side tool call must not be terminated early — reducing +# the default below 600s would kill currently-working turns (gate finding, #5789). +# The win here is that a read timeout is now TERMINAL and Stop-honoring (the old +# flat timeout ignored Stop on a half-open gateway); the budget itself stays 600s +# for backward compatibility. Deployments that want a tighter dead-gateway cap can +# lower ``HERMES_WEBUI_GATEWAY_READ_TIMEOUT``. _GATEWAY_READ_TIMEOUT_ENV = "HERMES_WEBUI_GATEWAY_READ_TIMEOUT" -_GATEWAY_READ_TIMEOUT_DEFAULT = 120.0 +_GATEWAY_READ_TIMEOUT_DEFAULT = 600.0 def _gateway_read_timeout_secs() -> float: - """Total byte-silence budget for gateway SSE reads (default 120s, env-tunable).""" + """Total byte-silence budget for gateway SSE reads (default 600s, env-tunable).""" raw = os.environ.get(_GATEWAY_READ_TIMEOUT_ENV) if raw: try: diff --git a/tests/test_gateway_read_idle_timeout.py b/tests/test_gateway_read_idle_timeout.py index 49139c5c..098eb85f 100644 --- a/tests/test_gateway_read_idle_timeout.py +++ b/tests/test_gateway_read_idle_timeout.py @@ -8,7 +8,7 @@ bytes) blocked each read for the full 600s, so the worker thread was pinned for 10 minutes and a user's Stop was ignored until then. The socket now carries a bounded byte-silence budget (``_gateway_read_timeout_secs``, -default 120s, env-tunable) and iteration goes through ``_iter_sse_lines_cancellable``. +default 600s, env-tunable) and iteration goes through ``_iter_sse_lines_cancellable``. A read timeout is TERMINAL: CPython's ``socket.makefile`` latches ``_timeout_occurred`` on the first ``socket.timeout``, so every subsequent read raises a bare ``OSError`` and the connection cannot be resumed. The generator @@ -172,10 +172,10 @@ def test_eof_ends_iteration_cleanly(): class TestReadTimeoutConfig: - def test_default_is_120s(self, monkeypatch): + def test_default_is_600s(self, monkeypatch): monkeypatch.delenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", raising=False) - assert _gateway_read_timeout_secs() == 120.0 - assert _GATEWAY_READ_TIMEOUT_DEFAULT == 120.0 + assert _gateway_read_timeout_secs() == 600.0 + assert _GATEWAY_READ_TIMEOUT_DEFAULT == 600.0 def test_env_override(self, monkeypatch): monkeypatch.setenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", "300") @@ -183,12 +183,12 @@ class TestReadTimeoutConfig: def test_invalid_env_falls_back_to_default(self, monkeypatch): monkeypatch.setenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", "not-a-number") - assert _gateway_read_timeout_secs() == 120.0 + assert _gateway_read_timeout_secs() == 600.0 def test_non_positive_env_falls_back_to_default(self, monkeypatch): monkeypatch.setenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", "0") - assert _gateway_read_timeout_secs() == 120.0 + assert _gateway_read_timeout_secs() == 600.0 def test_negative_env_falls_back_to_default(self, monkeypatch): monkeypatch.setenv("HERMES_WEBUI_GATEWAY_READ_TIMEOUT", "-5") - assert _gateway_read_timeout_secs() == 120.0 + assert _gateway_read_timeout_secs() == 600.0