Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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) ─────────────────────────────────────────
Expand All @@ -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:
Expand Down
90 changes: 90 additions & 0 deletions tests/test_server_port_exclusivity.py
Original file line number Diff line number Diff line change
@@ -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)
Loading