fix(http): don't let a half-open TLS connection wedge the accept loop
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:
1 parent
8657e6afc6
commit
2c20b1c8a5
2 files changed
+216
-12
No files matched your search
+133
-1
@@ -29,11 +29,14 @@ import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
from http.client import HTTPConnection
|
||||
from http.client import HTTPConnection, HTTPSConnection
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -835,6 +838,135 @@ async def test_media_pull_denied_path_404(gw):
|
||||
assert body["payload"]["code"] == "not_found"
|
||||
|
||||
|
||||
# ── TLS: a half-open connection must not wedge the accept loop ─────────────
|
||||
#
|
||||
# Regression (ARIA journal 2026-09-11 / 2026-09-23): the listening socket
|
||||
# used to be wrapped in a server-side ssl.SSLSocket, so serve_forever's
|
||||
# accept() ran the TLS handshake inline. A client that completed TCP but
|
||||
# vanished mid-handshake (a phone losing its network/VPN while traveling)
|
||||
# blocked do_handshake() 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.
|
||||
|
||||
|
||||
def _make_self_signed_cert(tmp_path: Path) -> tuple[Path, Path] | None:
|
||||
"""Self-signed cert + key for the TLS tests; None when
|
||||
``cryptography`` is unavailable (the tests then skip)."""
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
except ImportError:
|
||||
return None
|
||||
import datetime
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "iris-test")])
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=1))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName(
|
||||
[
|
||||
x509.DNSName("localhost"),
|
||||
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
|
||||
]
|
||||
),
|
||||
critical=False,
|
||||
)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
cert_path = tmp_path / "iris-test.crt"
|
||||
key_path = tmp_path / "iris-test.key"
|
||||
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
key_path.write_bytes(
|
||||
key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def gw_tls(adapter, tmp_path, monkeypatch):
|
||||
"""Connected adapter with the HTTP leg TLS-enabled; the handshake
|
||||
timeout is shortened so the half-open connection cleans itself up
|
||||
quickly."""
|
||||
paths = _make_self_signed_cert(tmp_path)
|
||||
if paths is None:
|
||||
pytest.skip("cryptography not available; TLS wedge test skipped")
|
||||
cert_path, key_path = paths
|
||||
plugin = _load_plugin()
|
||||
monkeypatch.setattr(
|
||||
plugin.http_server._ThreadingHTTPD, "HANDSHAKE_TIMEOUT_S", 0.5, raising=False
|
||||
)
|
||||
adapter.http_cert = str(cert_path)
|
||||
adapter.http_key = str(key_path)
|
||||
await adapter.connect()
|
||||
try:
|
||||
yield adapter
|
||||
finally:
|
||||
await adapter.disconnect()
|
||||
|
||||
|
||||
def _tls_health(port: int) -> int:
|
||||
"""GET /v1/health over a fresh TLS connection; returns the status."""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
conn = HTTPSConnection("127.0.0.1", port, timeout=5.0, context=ctx)
|
||||
conn.request("GET", "/v1/health")
|
||||
resp = conn.getresponse()
|
||||
status = resp.status
|
||||
resp.read()
|
||||
conn.close()
|
||||
return status
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_half_open_tls_connection_does_not_wedge_accept_loop(gw_tls):
|
||||
"""A client that completes TCP but never finishes the TLS handshake
|
||||
must not stop the server from accepting new connections (see section
|
||||
comment for the incident)."""
|
||||
port = http_port(gw_tls)
|
||||
|
||||
# 1) Half-open connection: TCP established, then silence — the
|
||||
# phone-loses-its-VPN scenario (the ClientHello never arrives).
|
||||
wedge = socket.create_connection(("127.0.0.1", port), timeout=5.0)
|
||||
try:
|
||||
# 2) While the half-open connection sits un-handshaked, a fresh,
|
||||
# well-formed TLS connection must still be accepted promptly.
|
||||
deadline = time.monotonic() + 10.0
|
||||
status = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status = await asyncio.to_thread(_tls_health, port)
|
||||
break
|
||||
except OSError:
|
||||
await asyncio.sleep(0.2)
|
||||
assert status == 200, f"health over TLS failed (status={status})"
|
||||
|
||||
# 3) Teardown must stay bounded with the half-open connection still
|
||||
# open: stop() used to block the event loop on httpd.shutdown()
|
||||
# until the shutdown watchdog killed the process.
|
||||
t0 = time.monotonic()
|
||||
await gw_tls._http_server.stop()
|
||||
assert time.monotonic() - t0 < 15.0
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
wedge.close()
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user