From 7bbae5af303a7a6798b7e9ae4de0b9650fda136d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 2 Jun 2026 18:35:49 -0400 Subject: [PATCH 1/3] fix(server): disable SO_REUSEADDR to prevent silent port sharing (#3289) --- server.py | 1 + tests/test_server_no_reuse_address.py | 7 +++++++ 2 files changed, 8 insertions(+) create mode 100644 tests/test_server_no_reuse_address.py diff --git a/server.py b/server.py index 39ba81c3a1..b4c1b99dbf 100644 --- a/server.py +++ b/server.py @@ -173,6 +173,7 @@ def _build_csp_report_only_policy() -> str: class QuietHTTPServer(ThreadingHTTPServer): """Custom HTTP server that silently handles common network errors.""" + allow_reuse_address = False # prevent silent port sharing (#3289) daemon_threads = True request_queue_size = 64 diff --git a/tests/test_server_no_reuse_address.py b/tests/test_server_no_reuse_address.py new file mode 100644 index 0000000000..2d06645296 --- /dev/null +++ b/tests/test_server_no_reuse_address.py @@ -0,0 +1,7 @@ +"""QuietHTTPServer must NOT set SO_REUSEADDR — a second instance on the +same port should fail loudly, not silently share traffic.""" + + +def test_allow_reuse_address_is_false(): + from server import QuietHTTPServer + assert QuietHTTPServer.allow_reuse_address is False From 9dbe92f3f6914e6a8940fc0ca3fb96b85e235358 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 2 Jun 2026 21:24:10 -0400 Subject: [PATCH 2/3] fix(server): live-listener probe + SO_EXCLUSIVEADDRUSE instead of disabling SO_REUSEADDR (#3289) --- server.py | 29 ++++++++- tests/test_server_no_reuse_address.py | 7 --- tests/test_server_port_exclusivity.py | 90 +++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 8 deletions(-) delete mode 100644 tests/test_server_no_reuse_address.py create mode 100644 tests/test_server_port_exclusivity.py diff --git a/server.py b/server.py index b4c1b99dbf..95a28977c1 100644 --- a/server.py +++ b/server.py @@ -173,7 +173,6 @@ def _build_csp_report_only_policy() -> str: class QuietHTTPServer(ThreadingHTTPServer): """Custom HTTP server that silently handles common network errors.""" - allow_reuse_address = False # prevent silent port sharing (#3289) daemon_threads = True request_queue_size = 64 @@ -185,6 +184,13 @@ def __init__(self, *args, **kwargs): self.accept_loop_requests_total = 0 self.accept_loop_last_request_at = 0.0 + def server_bind(self): + if sys.platform == 'win32': + self.allow_reuse_address = False + SO_EXCLUSIVEADDRUSE = getattr(socket, 'SO_EXCLUSIVEADDRUSE', -5) + self.socket.setsockopt(socket.SOL_SOCKET, SO_EXCLUSIVEADDRUSE, 1) + super().server_bind() + def _handle_request_noblock(self): """Record accept-loop progress before dispatching a request handler. @@ -478,6 +484,25 @@ def _log_shutdown_audit(reason: str = "serve_forever_exit") -> None: ) +def _abort_if_already_serving(host: str, port: int) -> None: + """Refuse to start if a live HTTP server is already responding on this port.""" + probe_host = '127.0.0.1' if host in ('0.0.0.0', '', '::') else host + try: + with socket.create_connection((probe_host, port), timeout=2) as s: + s.sendall(b'GET /health HTTP/1.0\r\nHost: localhost\r\n\r\n') + s.settimeout(2) + data = s.recv(512) + if data: + print( + f'[!!] FATAL: Another server is already responding on' + f' {probe_host}:{port}. Stop the existing instance first.', + flush=True, + ) + sys.exit(1) + except (ConnectionRefusedError, ConnectionResetError, OSError, socket.timeout): + pass + + def main() -> None: from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND @@ -573,6 +598,7 @@ def main() -> None: except Exception as e: print(f'[!!] WARNING: Plugin loading failed: {e}', flush=True) + _abort_if_already_serving(HOST, PORT) httpd = QuietHTTPServer((HOST, PORT), Handler) # ── TLS/HTTPS setup (optional) ───────────────────────────────────────── @@ -598,6 +624,7 @@ def main() -> None: try: httpd.serve_forever() finally: + httpd.server_close() _log_shutdown_audit() # Stop the gateway watcher on shutdown try: diff --git a/tests/test_server_no_reuse_address.py b/tests/test_server_no_reuse_address.py deleted file mode 100644 index 2d06645296..0000000000 --- a/tests/test_server_no_reuse_address.py +++ /dev/null @@ -1,7 +0,0 @@ -"""QuietHTTPServer must NOT set SO_REUSEADDR — a second instance on the -same port should fail loudly, not silently share traffic.""" - - -def test_allow_reuse_address_is_false(): - from server import QuietHTTPServer - assert QuietHTTPServer.allow_reuse_address is False diff --git a/tests/test_server_port_exclusivity.py b/tests/test_server_port_exclusivity.py new file mode 100644 index 0000000000..7f4889fb7d --- /dev/null +++ b/tests/test_server_port_exclusivity.py @@ -0,0 +1,90 @@ +"""Duplicate-instance guard: a second server on the same port must be detected +and refused before bind, not silently shared (#3289).""" + +from __future__ import annotations + +import socket +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pytest + +from tests._pytest_port import BASE + + +# ── SO_EXCLUSIVEADDRUSE on Windows ────────────────────────────────────────── + +@pytest.mark.skipif(sys.platform != 'win32', reason='Windows-only socket option') +def test_exclusive_addr_use_set_on_windows(): + from server import QuietHTTPServer + port = BASE + 901 + httpd = QuietHTTPServer(('127.0.0.1', port), BaseHTTPRequestHandler) + try: + val = httpd.socket.getsockopt( + socket.SOL_SOCKET, + getattr(socket, 'SO_EXCLUSIVEADDRUSE', -5), + ) + assert val != 0, 'SO_EXCLUSIVEADDRUSE should be set on Windows' + finally: + httpd.server_close() + + +# ── Live-listener probe ───────────────────────────────────────────────────── + +def test_probe_detects_live_server(): + """_abort_if_already_serving must call sys.exit when a live server responds.""" + from server import _abort_if_already_serving + + port = BASE + 902 + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(b'ok') + def log_message(self, *a): + pass + + httpd = HTTPServer(('127.0.0.1', port), Handler) + t = threading.Thread(target=httpd.serve_forever, daemon=True) + t.start() + try: + with pytest.raises(SystemExit): + _abort_if_already_serving('127.0.0.1', port) + finally: + httpd.shutdown() + httpd.server_close() + + +def test_probe_allows_startup_when_nothing_listening(): + """_abort_if_already_serving must return normally on a free port.""" + from server import _abort_if_already_serving + + port = BASE + 903 + _abort_if_already_serving('127.0.0.1', port) + + +def test_probe_allows_startup_on_unresponsive_socket(): + """A socket that accepts but never responds (e.g. dying instance still in + kernel backlog) should not block startup.""" + from server import _abort_if_already_serving + + port = BASE + 904 + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(('127.0.0.1', port)) + srv.listen(1) + try: + _abort_if_already_serving('127.0.0.1', port) + finally: + srv.close() + + +def test_probe_normalizes_wildcard_host(): + """0.0.0.0 and :: should probe 127.0.0.1, not the literal wildcard.""" + from server import _abort_if_already_serving + + port = BASE + 905 + _abort_if_already_serving('0.0.0.0', port) + _abort_if_already_serving('::', port) From 4f2f19a4de05fd5901901dbac32a73b9164cffaa Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 2 Jun 2026 21:28:50 -0400 Subject: [PATCH 3/3] fix(test): use TEST_PORT instead of BASE for port arithmetic --- tests/test_server_port_exclusivity.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_server_port_exclusivity.py b/tests/test_server_port_exclusivity.py index 7f4889fb7d..89eaf4176d 100644 --- a/tests/test_server_port_exclusivity.py +++ b/tests/test_server_port_exclusivity.py @@ -10,7 +10,7 @@ import pytest -from tests._pytest_port import BASE +from tests._pytest_port import TEST_PORT # ── SO_EXCLUSIVEADDRUSE on Windows ────────────────────────────────────────── @@ -18,7 +18,7 @@ @pytest.mark.skipif(sys.platform != 'win32', reason='Windows-only socket option') def test_exclusive_addr_use_set_on_windows(): from server import QuietHTTPServer - port = BASE + 901 + port = TEST_PORT + 901 httpd = QuietHTTPServer(('127.0.0.1', port), BaseHTTPRequestHandler) try: val = httpd.socket.getsockopt( @@ -36,7 +36,7 @@ def test_probe_detects_live_server(): """_abort_if_already_serving must call sys.exit when a live server responds.""" from server import _abort_if_already_serving - port = BASE + 902 + port = TEST_PORT + 902 class Handler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 @@ -61,7 +61,7 @@ def test_probe_allows_startup_when_nothing_listening(): """_abort_if_already_serving must return normally on a free port.""" from server import _abort_if_already_serving - port = BASE + 903 + port = TEST_PORT + 903 _abort_if_already_serving('127.0.0.1', port) @@ -70,7 +70,7 @@ def test_probe_allows_startup_on_unresponsive_socket(): kernel backlog) should not block startup.""" from server import _abort_if_already_serving - port = BASE + 904 + port = TEST_PORT + 904 srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(('127.0.0.1', port)) @@ -85,6 +85,6 @@ def test_probe_normalizes_wildcard_host(): """0.0.0.0 and :: should probe 127.0.0.1, not the literal wildcard.""" from server import _abort_if_already_serving - port = BASE + 905 + port = TEST_PORT + 905 _abort_if_already_serving('0.0.0.0', port) _abort_if_already_serving('::', port)