From 598fd4ff83a7c8dde920e7710878a0c5eb6d493f Mon Sep 17 00:00:00 2001 From: Qi Date: Sun, 24 May 2026 05:03:35 +0000 Subject: [PATCH] perf(http): enable HTTP/1.1 keep-alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/auth.py | 5 ++++- api/kanban_bridge.py | 2 +- api/routes.py | 27 +++++++++++++++++---------- server.py | 7 +++++++ 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/api/auth.py b/api/auth.py index df5f0e4c..5a49516f 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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 diff --git a/api/kanban_bridge.py b/api/kanban_bridge.py index 63bef9cd..f0d5d261 100644 --- a/api/kanban_bridge.py +++ b/api/kanban_bridge.py @@ -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 diff --git a/api/routes.py b/api/routes.py index 64fa3d9e..fe0ddd0e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 diff --git a/server.py b/server.py index e6636209..e6a2c65a 100644 --- a/server.py +++ b/server.py @@ -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):