Files
hermes-webui/tests/test_issue5210_http_worker_bound.py
T

362 lines
13 KiB
Python

"""Regression coverage for issue #5210 worker-bound request dispatch."""
from __future__ import annotations
import http.client
import socket
import ssl
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler
from pathlib import Path
import pytest
from server import QuietHTTPServer
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _worker_count(server: _ObservedQuietHTTPServer) -> int:
with server.worker_lock:
return server.worker_starts
def _wait_for_worker_count(server: _ObservedQuietHTTPServer, expected: int, *, timeout: float = 2.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
current = _worker_count(server)
if current == expected:
return
if current > expected:
raise AssertionError(f"expected worker count {expected}, got {current}")
time.sleep(0.01)
raise AssertionError(f"timed out waiting for worker count {expected}, got {_worker_count(server)}")
def _assert_worker_count_stays(server: _ObservedQuietHTTPServer, expected: int, *, duration: float = 0.35) -> None:
deadline = time.monotonic() + duration
while time.monotonic() < deadline:
current = _worker_count(server)
assert current == expected, f"expected worker count {expected}, got {current}"
time.sleep(0.01)
def _wait_for_worker_slot_release(server: _ObservedQuietHTTPServer, *, timeout: float = 2.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if server._request_worker_slots.acquire(blocking=False):
server._request_worker_slots.release()
return
time.sleep(0.01)
raise AssertionError("timed out waiting for worker slot release")
def _request(port: int, path: str, *, use_ssl: bool = False, timeout: float = 2.0) -> tuple[int, dict[str, str], bytes]:
if use_ssl:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection("127.0.0.1", port, timeout=timeout, context=context)
else:
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
try:
conn.request("GET", path)
resp = conn.getresponse()
body = resp.read()
return resp.status, {k: v for k, v in resp.getheaders()}, body
finally:
conn.close()
def _start_request_thread(port: int, path: str, *, use_ssl: bool = False) -> tuple[threading.Thread, dict[str, object]]:
result: dict[str, object] = {}
def runner() -> None:
try:
result["value"] = _request(port, path, use_ssl=use_ssl, timeout=5.0)
except Exception as exc: # pragma: no cover - only used for failure diagnostics
result["error"] = exc
thread = threading.Thread(target=runner, daemon=True)
thread.start()
return thread, result
def _generate_self_signed_cert(tmp_path: Path) -> tuple[str, str]:
cert = tmp_path / "cert.pem"
key = tmp_path / "key.pem"
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
str(key),
"-out",
str(cert),
"-days",
"1",
"-nodes",
"-subj",
"/CN=localhost",
],
check=True,
capture_output=True,
)
return str(cert), str(key)
class _ObservedQuietHTTPServer(QuietHTTPServer):
def __init__(self, *args, **kwargs):
self.worker_lock = threading.Lock()
self.worker_starts = 0
self.worker_started = threading.Event()
self.release_event = threading.Event()
self.hold_entered = threading.Event()
self.error_entered = threading.Event()
self.disconnect_entered = threading.Event()
super().__init__(*args, **kwargs)
def process_request_thread(self, request, client_address):
with self.worker_lock:
self.worker_starts += 1
self.worker_started.set()
return super().process_request_thread(request, client_address)
class _GateHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args) -> None: # pragma: no cover - noise suppression
pass
def _send_ok(self) -> None:
body = b"ok"
self.send_response(200)
self.send_header("Connection", "close")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self): # noqa: N802
if self.path == "/hold":
self.server.hold_entered.set()
if not self.server.release_event.wait(timeout=5):
raise TimeoutError("release_event not set")
self._send_ok()
return
if self.path == "/boom":
self.server.error_entered.set()
if not self.server.release_event.wait(timeout=5):
raise TimeoutError("release_event not set")
raise RuntimeError("boom")
if self.path == "/disconnect":
self.server.disconnect_entered.set()
if not self.server.release_event.wait(timeout=5):
raise TimeoutError("release_event not set")
self._send_ok()
return
if self.path == "/fast":
self._send_ok()
return
self.send_error(404)
class _ServerRunner:
def __init__(self, handler_cls, *, ssl_context=None):
self.port = _free_port()
self.httpd = _ObservedQuietHTTPServer(("127.0.0.1", self.port), handler_cls)
self.httpd.ssl_context = ssl_context
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
def __enter__(self):
self.thread.start()
time.sleep(0.1)
return self
def __exit__(self, exc_type, exc, tb):
self.httpd.release_event.set()
self.httpd.shutdown()
self.httpd.server_close()
self.thread.join(timeout=5)
def test_over_capacity_plain_http_gets_fast_503_without_starting_handler(monkeypatch):
monkeypatch.setattr(QuietHTTPServer, "max_request_workers", 1, raising=False)
with _ServerRunner(_GateHandler) as srv:
hold_thread, hold_result = _start_request_thread(srv.port, "/hold")
assert srv.httpd.hold_entered.wait(timeout=5)
_wait_for_worker_count(srv.httpd, 1)
status, headers, body = _request(srv.port, "/fast")
assert status == 503
assert headers["Connection"].lower() == "close"
assert body == b""
_assert_worker_count_stays(srv.httpd, 1)
srv.httpd.release_event.set()
hold_thread.join(timeout=5)
assert "error" not in hold_result, hold_result.get("error")
assert hold_result["value"][0] == 200
def test_slow_overflow_cleanup_does_not_block_later_rejects(monkeypatch):
monkeypatch.setattr(QuietHTTPServer, "max_request_workers", 1, raising=False)
drain_entered = threading.Event()
release_drain = threading.Event()
original_drain = QuietHTTPServer._drain_request_input_nonblocking
calls = 0
def blocking_first_drain(self, request) -> None:
nonlocal calls
calls += 1
if calls == 1:
drain_entered.set()
assert release_drain.wait(timeout=5)
return
original_drain(self, request)
monkeypatch.setattr(QuietHTTPServer, "_drain_request_input_nonblocking", blocking_first_drain)
with _ServerRunner(_GateHandler) as srv:
hold_thread, hold_result = _start_request_thread(srv.port, "/hold")
assert srv.httpd.hold_entered.wait(timeout=5)
_wait_for_worker_count(srv.httpd, 1)
slow_sock = socket.create_connection(("127.0.0.1", srv.port), timeout=2)
try:
assert drain_entered.wait(timeout=5)
status, headers, body = _request(srv.port, "/fast", timeout=1)
assert status == 503
assert headers["Connection"].lower() == "close"
assert body == b""
_assert_worker_count_stays(srv.httpd, 1)
finally:
release_drain.set()
slow_sock.close()
srv.httpd.release_event.set()
hold_thread.join(timeout=5)
assert "error" not in hold_result, hold_result.get("error")
assert hold_result["value"][0] == 200
def test_worker_slot_releases_after_request_finishes(monkeypatch):
monkeypatch.setattr(QuietHTTPServer, "max_request_workers", 1, raising=False)
with _ServerRunner(_GateHandler) as srv:
hold_thread, hold_result = _start_request_thread(srv.port, "/hold")
assert srv.httpd.hold_entered.wait(timeout=5)
_wait_for_worker_count(srv.httpd, 1)
status, headers, body = _request(srv.port, "/fast")
assert status == 503
assert headers["Connection"].lower() == "close"
assert body == b""
srv.httpd.release_event.set()
hold_thread.join(timeout=5)
assert "error" not in hold_result, hold_result.get("error")
assert hold_result["value"][0] == 200
status, headers, body = _request(srv.port, "/fast")
assert status == 200
assert headers["Connection"].lower() == "close"
assert body == b"ok"
@pytest.mark.parametrize("mode", ["boom", "disconnect"])
def test_worker_slot_releases_after_handler_error_or_disconnect(monkeypatch, mode):
monkeypatch.setattr(QuietHTTPServer, "max_request_workers", 1, raising=False)
with _ServerRunner(_GateHandler) as srv:
if mode == "boom":
held_thread, _ = _start_request_thread(srv.port, "/boom")
assert srv.httpd.error_entered.wait(timeout=5)
else:
sock = socket.create_connection(("127.0.0.1", srv.port), timeout=2)
sock.sendall(
b"GET /disconnect HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Connection: close\r\n"
b"\r\n"
)
held_thread = None
assert srv.httpd.disconnect_entered.wait(timeout=5)
_wait_for_worker_count(srv.httpd, 1)
status, headers, body = _request(srv.port, "/fast")
assert status == 503
assert headers["Connection"].lower() == "close"
assert body == b""
if mode == "boom":
srv.httpd.release_event.set()
held_thread.join(timeout=5)
assert not held_thread.is_alive()
else:
sock.close()
srv.httpd.release_event.set()
_wait_for_worker_slot_release(srv.httpd)
status, headers, body = _request(srv.port, "/fast")
assert status == 200
assert headers["Connection"].lower() == "close"
assert body == b"ok"
def test_long_running_request_does_not_reject_healthy_in_cap_request(monkeypatch):
monkeypatch.setattr(QuietHTTPServer, "max_request_workers", 2, raising=False)
with _ServerRunner(_GateHandler) as srv:
hold_thread, hold_result = _start_request_thread(srv.port, "/hold")
assert srv.httpd.hold_entered.wait(timeout=5)
_wait_for_worker_count(srv.httpd, 1)
status, headers, body = _request(srv.port, "/fast")
assert status == 200
assert headers["Connection"].lower() == "close"
assert body == b"ok"
_wait_for_worker_count(srv.httpd, 2)
srv.httpd.release_event.set()
hold_thread.join(timeout=5)
assert "error" not in hold_result, hold_result.get("error")
assert hold_result["value"][0] == 200
def test_tls_overflow_does_not_force_accept_loop_handshake(monkeypatch, tmp_path):
monkeypatch.setattr(QuietHTTPServer, "max_request_workers", 1, raising=False)
cert, key = _generate_self_signed_cert(tmp_path)
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_context.load_cert_chain(cert, key)
with _ServerRunner(_GateHandler, ssl_context=server_context) as srv:
hold_thread, hold_result = _start_request_thread(srv.port, "/hold", use_ssl=True)
assert srv.httpd.hold_entered.wait(timeout=5)
_wait_for_worker_count(srv.httpd, 1)
raw = socket.create_connection(("127.0.0.1", srv.port), timeout=2)
try:
raw.settimeout(2)
assert raw.recv(1) == b""
finally:
raw.close()
_assert_worker_count_stays(srv.httpd, 1)
srv.httpd.release_event.set()
hold_thread.join(timeout=5)
assert "error" not in hold_result, hold_result.get("error")
assert hold_result["value"][0] == 200