perf(http): enable HTTP/1.1 keep-alive

Enable HTTP/1.1 on the WebUI server so browsers can reuse TCP
connections across normal API/static requests. Tighten response framing
by adding Content-Length to short manual responses and marking
SSE/streaming responses as Connection: close, keeping HTTP/1.1 message
boundaries unambiguous.

Verified:
- python3 -m py_compile server.py api/auth.py api/routes.py api/kanban_bridge.py
- pytest tests/test_auth_*.py tests/test_*sse*.py tests/test_pr1350_*.py
        tests/test_pr1355_sse_handler_no_deadlock.py tests/test_kanban_bridge.py
        tests/test_logs_ui_static.py tests/test_onboarding_static.py
        tests/test_regressions.py tests/test_1038_pwa_auth_redirect.py
        tests/test_issue1623_sse_heartbeat_alignment.py
  → 239 passed, 1 skipped
This commit is contained in:
Qi
2026-05-24 05:03:35 +00:00
committed by hermes-agent
parent 01f01b9cbe
commit 598fd4ff83
4 changed files with 29 additions and 12 deletions
+4 -1
View File
@@ -435,10 +435,12 @@ def check_auth(handler, parsed) -> bool:
return True
# Not authorized
if parsed.path.startswith('/api/'):
body = b'{"error":"Authentication required"}'
handler.send_response(401)
handler.send_header('Content-Type', 'application/json')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(b'{"error":"Authentication required"}')
handler.wfile.write(body)
else:
handler.send_response(302)
# Pass the original path as ?next= so login.js redirects back after auth.
@@ -468,6 +470,7 @@ def check_auth(handler, parsed) -> bool:
# `?`, `&`, `=`) gets percent-encoded.
_next = _urlparse.quote(_path_with_query, safe='/')
handler.send_header('Location', 'login?next=' + _next)
handler.send_header('Content-Length', '0')
handler.end_headers()
return False
+1 -1
View File
@@ -1022,7 +1022,7 @@ def _handle_events_sse_stream(handler, parsed):
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
handler.send_header("Connection", "close")
handler.end_headers()
# Send an initial frame so the client knows the connection is open
+17 -10
View File
@@ -6216,13 +6216,15 @@ def handle_post(handler, parsed) -> bool:
_record_login_attempt(client_ip)
return bad(handler, "Invalid password", 401)
cookie_val = create_session()
body = json.dumps({"ok": True}).encode()
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
_security_headers(handler)
set_auth_cookie(handler, cookie_val)
handler.end_headers()
handler.wfile.write(json.dumps({"ok": True}).encode())
handler.wfile.write(body)
return True
if parsed.path == "/api/auth/logout":
@@ -6231,13 +6233,15 @@ def handle_post(handler, parsed) -> bool:
cookie_val = parse_cookie(handler)
if cookie_val:
invalidate_session(cookie_val)
body = json.dumps({"ok": True}).encode()
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
_security_headers(handler)
clear_auth_cookie(handler)
handler.end_headers()
handler.wfile.write(json.dumps({"ok": True}).encode())
handler.wfile.write(body)
return True
# ── Checkpoints / Rollback (POST) ──
@@ -6577,7 +6581,7 @@ def _handle_sse_stream(handler, parsed):
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
handler.send_header("Connection", "close")
handler.end_headers()
try:
_replay_run_journal(handler, stream_id, _parse_run_journal_after_seq(qs))
@@ -6589,7 +6593,7 @@ def _handle_sse_stream(handler, parsed):
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
handler.send_header("Connection", "close")
handler.end_headers()
try:
while True:
@@ -6720,7 +6724,7 @@ def _handle_terminal_output(handler, parsed):
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
handler.send_header("Connection", "close")
handler.end_headers()
try:
while True:
@@ -6798,7 +6802,7 @@ def _handle_gateway_sse_stream(handler, parsed):
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
handler.send_header('Cache-Control', 'no-cache')
handler.send_header('X-Accel-Buffering', 'no')
handler.send_header('Connection', 'keep-alive')
handler.send_header('Connection', 'close')
handler.end_headers()
q = watcher.subscribe()
@@ -6831,7 +6835,7 @@ def _handle_session_events_stream(handler):
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
handler.send_header('Cache-Control', 'no-cache')
handler.send_header('X-Accel-Buffering', 'no')
handler.send_header('Connection', 'keep-alive')
handler.send_header('Connection', 'close')
handler.end_headers()
q = subscribe_session_events()
@@ -6917,6 +6921,7 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
handler.send_response(416)
handler.send_header("Content-Range", f"bytes */{file_size}")
handler.send_header("Accept-Ranges", "bytes")
handler.send_header("Content-Length", "0")
_security_headers(handler)
handler.end_headers()
return True
@@ -7027,10 +7032,12 @@ def _handle_media(handler, parsed):
if is_auth_enabled():
cv = parse_cookie(handler)
if not (cv and verify_session(cv)):
body = b'{"error":"Authentication required"}'
handler.send_response(401)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(b'{"error":"Authentication required"}')
handler.wfile.write(body)
return
qs = parse_qs(parsed.query)
@@ -7389,7 +7396,7 @@ def _handle_approval_sse_stream(handler, parsed):
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
handler.send_header('Cache-Control', 'no-cache')
handler.send_header('X-Accel-Buffering', 'no')
handler.send_header('Connection', 'keep-alive')
handler.send_header('Connection', 'close')
handler.end_headers()
from api.streaming import _sse
@@ -7490,7 +7497,7 @@ def _handle_clarify_sse_stream(handler, parsed):
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
handler.send_header('Cache-Control', 'no-cache')
handler.send_header('X-Accel-Buffering', 'no')
handler.send_header('Connection', 'keep-alive')
handler.send_header('Connection', 'close')
handler.end_headers()
from api.streaming import _sse
+7
View File
@@ -170,6 +170,13 @@ class QuietHTTPServer(ThreadingHTTPServer):
class Handler(BaseHTTPRequestHandler):
# HTTP/1.1 enables keep-alive connection reuse — major latency win on
# high-RTT links where every saved TCP handshake is 2×RTT. Each response
# MUST declare framing (Content-Length, Transfer-Encoding: chunked, or
# Connection: close) so the client knows where the message ends. Helpers
# j()/t() emit Content-Length; SSE/streaming endpoints emit
# Connection: close because the body has no terminator. See PR notes.
protocol_version = "HTTP/1.1"
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
def setup(self):