From 95b09d3f7809137ba8069738f77f3b8a436f3403 Mon Sep 17 00:00:00 2001 From: Frowtek Date: Sat, 18 Jul 2026 06:15:09 +0300 Subject: [PATCH] fix(gateway): route inbound-image decision off the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_prepare_inbound_message_text` (async) called `_decide_image_input_mode` inline for every inbound image. That decision is synchronous and does blocking network I/O on the way to a capability answer: - `agent.models_dev.fetch_models_dev` — an HTTP GET to models.dev (15s timeout) whenever the 1-hour in-memory cache is cold or models.dev is slow. - `agent.model_metadata.query_ollama_supports_vision` — HTTP probes (`detect_local_server_type` + `/api/show`) against a local Ollama server when the active provider fronts one. Running that inline blocks the gateway event loop for up to the request timeout — so a single user attaching an image freezes EVERY session on that gateway (no other messages processed, no heartbeats) until the fetch/probe returns or times out. This is the same off-the-loop class as the cron-fire verifier and the async_is_safe_url work. Wrap the call in `asyncio.to_thread` so the blocking capability lookup runs on a worker thread and the loop stays responsive. The decision result and routing are unchanged. Test: a gateway image-routing runtime test asserts the capability lookup runs off the main (event-loop) thread; it runs on the main thread before the fix. --- gateway/run.py | 8 +++- .../test_image_input_routing_runtime.py | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 61fcd96eb709..838b5b09d6f9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10840,7 +10840,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if image_paths: # Decide routing: native (attach pixels) vs text (vision_analyze # pre-run + prepend description). See agent/image_routing.py. - _img_mode = self._decide_image_input_mode( + # Offload to a worker thread: the decision does blocking network + # I/O — a models.dev fetch on cache miss, and the Ollama + # ``/api/show`` capability probe for local servers — whose + # request timeout would otherwise stall the whole gateway event + # loop (every session) while a single image is routed. + _img_mode = await asyncio.to_thread( + self._decide_image_input_mode, source=source, session_key=session_key, ) diff --git a/tests/gateway/test_image_input_routing_runtime.py b/tests/gateway/test_image_input_routing_runtime.py index bc5093580723..40bb65260cd3 100644 --- a/tests/gateway/test_image_input_routing_runtime.py +++ b/tests/gateway/test_image_input_routing_runtime.py @@ -143,3 +143,48 @@ async def test_prepare_image_routing_falls_back_to_text_for_text_only_session_ov session_key = runner._session_key_for_source(source) assert result == "[vision summary]\n\nlook" assert runner._pending_native_image_paths_by_session.get(session_key) is None + + +@pytest.mark.asyncio +async def test_prepare_image_routing_runs_off_the_event_loop(monkeypatch): + """The image-routing decision does blocking network I/O — a models.dev fetch + on cache miss, and the Ollama ``/api/show`` capability probe for local + servers — so it must run on a worker thread. Run inline on the gateway + event loop it would freeze *every* session for up to the request timeout + while a single image is routed. + """ + import threading + + runner = _make_runner() + source = _source() + event = _image_event() + cfg = _auto_config() + + monkeypatch.setattr("gateway.run._load_gateway_config", lambda: cfg) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg) + monkeypatch.setattr("agent.auxiliary_client._read_main_provider", lambda: "xiaomi") + monkeypatch.setattr("agent.auxiliary_client._read_main_model", lambda: "mimo-v2.5-pro") + monkeypatch.setattr( + runner, + "_resolve_session_agent_runtime", + lambda **_: ("gpt-5.5", {"provider": "openai-codex"}), + ) + + main_thread = threading.current_thread() + seen: dict = {} + + def recording_supports(provider, model, config): + # Stands in for the real, blocking capability lookup and records the + # thread it executes on. + seen["thread"] = threading.current_thread() + return True # vision-capable → native routing (skips _enrich_message_with_vision) + + monkeypatch.setattr("agent.image_routing._lookup_supports_vision", recording_supports) + + await runner._prepare_inbound_message_text(event=event, source=source, history=[]) + + assert seen.get("thread") is not None, "capability lookup was never reached" + assert seen["thread"] is not main_thread, ( + "the blocking image-routing decision must be offloaded off the gateway " + "event loop, not run inline on it" + )