diff --git a/server.py b/server.py index 39ba81c3a1..95a28977c1 100644 --- a/server.py +++ b/server.py @@ -184,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. @@ -477,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 @@ -572,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) ───────────────────────────────────────── @@ -597,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_port_exclusivity.py b/tests/test_server_port_exclusivity.py new file mode 100644 index 0000000000..89eaf4176d --- /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 TEST_PORT + + +# ── 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 = TEST_PORT + 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 = TEST_PORT + 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 = TEST_PORT + 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 = 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)) + 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 = TEST_PORT + 905 + _abort_if_already_serving('0.0.0.0', port) + _abort_if_already_serving('::', port)