diff --git a/tests/test_issue3103_sse_no_connection_close.py b/tests/test_issue3103_sse_no_connection_close.py new file mode 100644 index 00000000..1fcd86cc --- /dev/null +++ b/tests/test_issue3103_sse_no_connection_close.py @@ -0,0 +1,60 @@ +"""Regression test for #3103 / PR #3128. + +Commit 598fd4ff added `Connection: close` to the two long-lived SSE handlers +(`_handle_gateway_sse_stream`, `_handle_session_events_stream`). Browsers treat +`Connection: close` on an SSE response as a signal that the EventSource lifecycle +has ended and auto-reconnect, producing a reconnect storm that thrashes the +session list (#3103). These handlers must NOT emit `Connection: close`. + +Finite responses (the on-the-fly ZIP download, terminal SSE, etc.) still need the +header for unambiguous HTTP/1.1 message boundaries — this test only pins the two +long-lived session/gateway event streams. +""" + +import re +from pathlib import Path + +ROUTES = (Path(__file__).resolve().parents[1] / "api" / "routes.py").read_text(encoding="utf-8") + + +def _handler_body(name: str) -> str: + """Return the source of a handler function from its `def` to the next top-level `def`.""" + start = ROUTES.index(f"def {name}(") + # find the next top-level "def " after the function start + nxt = ROUTES.find("\ndef ", start + 1) + return ROUTES[start: nxt if nxt != -1 else len(ROUTES)] + + +def _emits_connection_close(body: str) -> bool: + """True if the handler actually CALLS send_header to emit Connection: close + (ignores mentions in comments).""" + return bool( + re.search(r"send_header\(\s*['\"]Connection['\"]\s*,\s*['\"]close['\"]", body) + ) + + +def test_session_events_stream_does_not_emit_connection_close(): + body = _handler_body("_handle_session_events_stream") + assert "text/event-stream" in body, "sanity: this is the SSE handler" + assert not _emits_connection_close(body), ( + "_handle_session_events_stream must not emit `Connection: close` — it triggers " + "browser EventSource reconnect storms on this long-lived stream (#3103)" + ) + + +def test_gateway_sse_stream_does_not_emit_connection_close(): + body = _handler_body("_handle_gateway_sse_stream") + assert "text/event-stream" in body, "sanity: this is the SSE handler" + assert not _emits_connection_close(body), ( + "_handle_gateway_sse_stream must not emit `Connection: close` — it triggers " + "browser EventSource reconnect storms on this long-lived stream (#3103)" + ) + + +def test_connection_close_still_present_for_finite_zip_download(): + """The on-the-fly ZIP download (no Content-Length) still needs Connection: close + for unambiguous HTTP/1.1 framing (#2836). Guard against an over-broad removal.""" + assert ROUTES.count('"Connection", "close"') + ROUTES.count("'Connection', 'close'") >= 1, ( + "at least the finite-response handlers (ZIP download, etc.) must keep " + "`Connection: close` for HTTP/1.1 message-boundary correctness (#2836)" + )