fix(http): don't let a half-open TLS connection wedge the accept loop
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 8m18s
CI / Gateway plugin tests (pull_request) Failing after 15m7s

A client that completes TCP but vanishes mid-TLS-handshake (e.g. a
phone losing its network/VPN while traveling) blocked
ssl.SSLSocket.accept() inside serve_forever forever: the gateway
stopped accepting any new device connections (the app could not
reconnect), and on the next restart httpd.shutdown() froze the whole
event loop until the shutdown watchdog killed the process (ARIA
journal 2026-09-11 / 2026-09-23).

- Move the TLS handshake out of the accept loop: it now runs in the
  per-connection thread under a hard timeout (HANDSHAKE_TIMEOUT_S,
  10 s); a failed/timed-out handshake just closes the socket.
- stop() no longer blocks the event loop: shutdown()/server_close()/
  join run in an executor under asyncio.wait_for(10 s); if the bound
  expires the daemon threads are abandoned.
- Regression test: a silent half-open TCP connection must not stop
  fresh TLS connections from being served, and stop() must stay
  bounded.
This commit is contained in:
ARIA committed 2026-09-23 15:10:16 +02:00
1 parent 8657e6afc6
commit 2c20b1c8a5
2 files changed
+216 -12

No files matched your search

+83 -11
View File
@@ -45,6 +45,7 @@ import contextlib
import json
import logging
import queue
import socket
import ssl
import threading
import time
@@ -220,7 +221,14 @@ class HttpServer:
if self._adapter.http_cert and self._adapter.http_key:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
# The handshake runs in the per-connection thread with a
# hard timeout (see _ThreadingHTTPD.process_request).
# Wrapping the *listening* socket here instead would make
# serve_forever's accept() block inside do_handshake() on
# a half-open connection (TCP established, client gone
# mid-handshake), wedging ALL new device connections until
# the gateway is restarted.
httpd.set_tls(ctx)
except Exception as e:
logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
self._release_lock()
@@ -245,17 +253,35 @@ class HttpServer:
s.q.put_nowait(_STOP)
httpd = self._httpd
self._httpd = None
if httpd is not None:
# shutdown() must be called from a thread other than the one
# running serve_forever(); we are on the asyncio loop thread.
with contextlib.suppress(Exception):
httpd.shutdown()
with contextlib.suppress(Exception):
httpd.server_close()
t = self._thread
self._thread = None
if t is not None and t is not threading.current_thread():
t.join(timeout=5.0)
if httpd is not None or t is not None:
# shutdown() blocks until the serve_forever loop exits and
# server_close() may join handler threads — both must run on a
# worker thread (never the asyncio loop thread) with a hard
# timeout, or a wedged server would freeze the whole gateway.
# The threads are daemons: if the bounded wait expires they die
# with the process and there is nothing left to do.
loop = asyncio.get_running_loop()
def _stop_httpd() -> None:
if httpd is not None:
with contextlib.suppress(Exception):
httpd.shutdown()
with contextlib.suppress(Exception):
httpd.server_close()
if t is not None and t is not threading.current_thread():
t.join(timeout=5.0)
try:
await asyncio.wait_for(
loop.run_in_executor(None, _stop_httpd),
timeout=10.0,
)
except Exception:
logger.warning(
"iris: HTTP server teardown did not finish in time; abandoning daemon threads"
)
self._release_lock()
def _release_lock(self) -> None:
@@ -783,14 +809,60 @@ class HttpServer:
class _ThreadingHTTPD(ThreadingHTTPServer):
"""One thread per connection (fine at single-user scale); daemon
threads so a stuck handler can't block process exit."""
threads so a stuck handler can't block process exit.
With TLS enabled (``set_tls``) the handshake runs in the
per-connection thread under a hard timeout — never in the
``serve_forever`` accept loop. ``ssl.SSLSocket.accept()`` would
otherwise block that loop inside ``do_handshake()`` on a half-open
connection (TCP established but the client vanished mid-handshake,
e.g. a phone losing its network/VPN), and the gateway would stop
accepting any new device connections until it is restarted.
"""
daemon_threads = True
allow_reuse_address = True
# A client that completes TCP but never finishes the TLS handshake
# must not hold the connection open indefinitely.
HANDSHAKE_TIMEOUT_S = 10.0
def __init__(self, addr: tuple[str, int], http_server: HttpServer):
super().__init__(addr, _Handler)
self.http_server = http_server
self._tls_ctx: ssl.SSLContext | None = None
def set_tls(self, ctx: ssl.SSLContext) -> None:
self._tls_ctx = ctx
def process_request( # noqa: A003 # type: ignore[override]
self, request: socket.socket, client_address: Any
) -> None:
"""Spawn the handler thread; with TLS, the handshake happens in
that thread first, under ``HANDSHAKE_TIMEOUT_S`` (see class
docstring). A failed/timed-out handshake just closes the socket —
the accept loop is never blocked by it."""
if self._tls_ctx is None:
super().process_request(request, client_address)
return
tls_ctx = self._tls_ctx
def _handshake_then_handle() -> None:
try:
request.settimeout(self.HANDSHAKE_TIMEOUT_S)
# wrap_socket() performs the handshake (default
# do_handshake_on_connect=True); restore blocking mode for
# the request handler afterwards.
tls_sock = tls_ctx.wrap_socket(request, server_side=True)
tls_sock.settimeout(None)
except OSError as e: # ssl.SSLError, timeout, reset, ...
with contextlib.suppress(OSError):
request.close()
logger.debug("iris http: TLS handshake failed (%s): %s", client_address, e)
return
super(_ThreadingHTTPD, self).process_request(tls_sock, client_address)
threading.Thread(target=_handshake_then_handle, name="iris-tls", daemon=True).start()
class _Handler(BaseHTTPRequestHandler):