diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6a58e72982..5115c2e530 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1,4 +1,5 @@ import json +import logging import os import subprocess import sys @@ -341,6 +342,38 @@ def test_compute_host_explicit_images_do_not_clear_later_attachment(monkeypatch) assert session["attached_images"] == ["/tmp/c.png"] +def test_prompt_submit_unknown_session_logs_warning(caplog): + """A submit against a reaped runtime id must leave a diagnosable trace. + + Regression for #90428: messages sent into a session whose in-memory + runtime was detached on WS disconnect and orphan-reaped vanished + silently — the 4001 was never logged, so "request arrived and was + rejected" was indistinguishable from "request never arrived". + """ + for session in list(server._sessions.values()): + server._teardown_session(session) + server._sessions.clear() + + with caplog.at_level(logging.WARNING, logger="tui_gateway.server"): + resp = _dispatch_sync( + { + "id": "r1", + "method": "prompt.submit", + "params": {"session_id": "gone-sid", "text": "hello"}, + } + ) + + assert resp == { + "jsonrpc": "2.0", + "id": "r1", + "error": {"code": 4001, "message": "session not found"}, + } + assert any( + "session-scoped RPC rejected" in rec.message and "gone-sid" in rec.message + for rec in caplog.records + ) + + def test_prompt_submit_fails_open_inline_when_compute_host_dispatch_breaks(monkeypatch): class _BrokenSupervisor: def submit_turn(self, frame, *, on_complete=None): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 04c58595ba..ce10d67ddd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2741,8 +2741,25 @@ def _start_agent_build(sid: str, session: dict) -> None: def _sess_nowait(params, rid): - s = _sessions.get(params.get("session_id") or "") - return (s, None) if s else (None, _err(rid, 4001, "session not found")) + sid = params.get("session_id") or "" + s = _sessions.get(sid) + if s: + return (s, None) + # A session-scoped RPC hit a runtime id the gateway no longer holds + # (detached on WS disconnect and orphan-reaped, LRU-evicted, or torn down + # after an idle TTL). The client is expected to recover via + # session.resume on the STORED session id, but a plain stale-id send + # leaves no trace anywhere when the resume never fires — every RPC in + # this class returned a silent 4001. Log it so a "message vanished" + # report is diagnosable as "request arrived and was rejected" instead of + # "request never arrived" (see #90428). + logger.warning( + "session-scoped RPC rejected: session_id=%r not in memory " + "(detached/reaped runtime; client should resume the stored session), rid=%r", + sid, + rid, + ) + return (None, _err(rid, 4001, "session not found")) def _sess(params, rid):