fix(bootstrap): race IPv6/IPv4 for every sync connect from the process bootstrap, not per client
Wire the racer #114277 added once, at the seam every Hermes process already crosses first: ``hermes_bootstrap`` (imported before anything else by ``hermes``, ``hermes-agent``, ``hermes-acp``, ``gateway.run``, ``batch_runner``, ``tui_gateway.entry`` and the slash worker). Installing it from ``init_agent`` was too late for the startup path the report is about — the TUI gateway starts MCP discovery and the model-catalog prewarm before any AIAgent exists, and ``hermes model`` / picker prewarm never build one. - Move the stdlib-only racer core (``_happy_eyeballs_create_connection``, ``_interleave_addrinfos``) and the installer into ``hermes_bootstrap`` and apply it on import; ``agent.process_bootstrap`` keeps only the httpcore backend and imports the racer from there. The bootstrap must stay stdlib-only because entry points call ``harden_import_path()`` after importing it. - Patch urllib3's own serial connect walker lazily through a one-shot ``sys.meta_path`` hook instead of importing urllib3 eagerly: ``hermes`` and the TUI gateway never load urllib3 at start, and importing it costs ~50 ms. - Idempotence by a marker on the installed function, so a re-import of the bootstrap (tests, ``importlib.reload``) never wraps the racer twice. - Drop the ``init_agent`` call site (redundant: ``process_bootstrap`` imports the bootstrap) and trim the five added tests to two invariants in the mirroring ``tests/test_hermes_bootstrap.py``; the racer unit test moves its monkeypatch seams to the new module. - Docs: ``network.force_ipv4`` now describes the default racing behaviour. Live A/B (stub resolver: blackholed 100::1 first, local IPv4 second, driving the real clients after ``import hermes_cli.main``): catalog fetch via requests 5.05s -> 0.39s, shared keepalive httpx client 15.24s -> 0.32s, inline httpx.Client 5.01s -> 0.26s; IPv4-only control 0.01s both sides. Refused ports still fail instantly with the same exception types; a blackhole-only host still raises ConnectTimeout at the configured connect timeout. Fixes #114265
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Windows UTF-8 bootstrap for Hermes entry points (no-op on POSIX).
|
||||
"""Process bootstrap for Hermes entry points: Windows UTF-8 stdio, import-path
|
||||
hardening, durable lazy-install target, and dual-stack (Happy Eyeballs) connects.
|
||||
|
||||
Windows binds stdio to the console code page (cp1252), so ``print("café")`` raises
|
||||
``UnicodeEncodeError``, and Python children inherit the same default unless
|
||||
@@ -7,15 +8,228 @@ point (``hermes``, ``hermes-agent``, ``hermes-acp``, ``gateway.run``, ``batch_ru
|
||||
``cron/scheduler``). It does NOT re-exec with ``-X utf8``: ``open()`` in the current
|
||||
process still needs an explicit ``encoding="utf-8"`` (ruff ``PLW1514``). POSIX is left
|
||||
alone deliberately — users' ``LANG``/``LC_*`` choices are respected.
|
||||
|
||||
Stdlib only: entry points import this before ``harden_import_path()`` runs, so nothing
|
||||
here may pull in a Hermes package that a project-local directory could shadow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import importlib.abc
|
||||
import importlib.util
|
||||
import os
|
||||
import selectors
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
_bootstrap_applied = False
|
||||
_HAPPY_EYEBALLS_DELAY_SECONDS = 0.25
|
||||
_URLLIB3_CONNECTION_MODULE = "urllib3.util.connection"
|
||||
|
||||
|
||||
def _interleave_addrinfos(addrinfos: list[tuple]) -> list[tuple]:
|
||||
"""Round-robin the resolved address families (deduped), preserving resolver order within each."""
|
||||
queues: dict[int, list[tuple]] = {}
|
||||
seen: set[tuple] = set()
|
||||
for addrinfo in addrinfos:
|
||||
family, socktype, proto, _canonname, sockaddr = addrinfo
|
||||
if (family, socktype, proto, sockaddr) not in seen:
|
||||
seen.add((family, socktype, proto, sockaddr))
|
||||
queues.setdefault(family, []).append(addrinfo)
|
||||
interleaved: list[tuple] = []
|
||||
while any(queues.values()):
|
||||
interleaved.extend(queue.pop(0) for queue in queues.values() if queue)
|
||||
return interleaved
|
||||
|
||||
|
||||
def _quiet_unregister(selector, sock) -> None:
|
||||
try:
|
||||
selector.unregister(sock)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _happy_eyeballs_create_connection(address: tuple[str, int], timeout: float | None,
|
||||
source_address: tuple[str, int] | None = None, socket_options=()):
|
||||
"""RFC 8305-style connect: staggered non-blocking attempts across families.
|
||||
|
||||
``socket.create_connection`` tries addresses serially, so broken-but-
|
||||
advertised IPv6 can burn the whole timeout per AAAA record before IPv4.
|
||||
"""
|
||||
host, port = address
|
||||
addrinfos = _interleave_addrinfos(socket.getaddrinfo(host, port, type=socket.SOCK_STREAM))
|
||||
if not addrinfos:
|
||||
raise OSError(f"getaddrinfo returned no addresses for {host}")
|
||||
|
||||
selector = selectors.DefaultSelector()
|
||||
active: set[socket.socket] = set()
|
||||
winner = None
|
||||
last_error: OSError | None = None
|
||||
deadline = None if timeout is None else time.monotonic() + max(timeout, 0.0)
|
||||
next_launch = time.monotonic()
|
||||
pending = list(addrinfos)
|
||||
in_progress = {0, errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY, errno.EINTR, getattr(errno, "WSAEWOULDBLOCK", 10035)}
|
||||
|
||||
def start_attempt(addrinfo):
|
||||
family, socktype, proto, _canonname, sockaddr = addrinfo
|
||||
candidate = socket.socket(family, socktype, proto)
|
||||
try:
|
||||
if source_address is not None:
|
||||
local_infos = socket.getaddrinfo(source_address[0], source_address[1], family=family, type=socktype)
|
||||
if not local_infos:
|
||||
raise OSError(f"getaddrinfo returned no local {family} address for {source_address[0]}")
|
||||
candidate.bind(local_infos[0][4])
|
||||
candidate.setblocking(False)
|
||||
result = candidate.connect_ex(sockaddr)
|
||||
if result in (0, errno.EISCONN):
|
||||
return candidate
|
||||
if result not in in_progress:
|
||||
raise OSError(result, os.strerror(result))
|
||||
selector.register(candidate, selectors.EVENT_WRITE)
|
||||
active.add(candidate)
|
||||
return None
|
||||
except Exception:
|
||||
candidate.close()
|
||||
raise
|
||||
|
||||
try:
|
||||
while pending or active:
|
||||
now = time.monotonic()
|
||||
if deadline is not None and now >= deadline:
|
||||
raise socket.timeout("timed out")
|
||||
if pending and now >= next_launch:
|
||||
try:
|
||||
winner = start_attempt(pending.pop(0))
|
||||
except OSError as exc:
|
||||
last_error = exc
|
||||
if not active:
|
||||
next_launch = now
|
||||
continue
|
||||
if winner is not None:
|
||||
break
|
||||
next_launch = now + _HAPPY_EYEBALLS_DELAY_SECONDS
|
||||
wait_timeout = None if deadline is None else max(0.0, deadline - now)
|
||||
if pending:
|
||||
until_launch = max(0.0, next_launch - now)
|
||||
wait_timeout = until_launch if wait_timeout is None else min(wait_timeout, until_launch)
|
||||
for key, _mask in selector.select(wait_timeout):
|
||||
candidate = key.fileobj
|
||||
error_code = candidate.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
|
||||
selector.unregister(candidate)
|
||||
active.discard(candidate)
|
||||
if error_code == 0:
|
||||
winner = candidate
|
||||
break
|
||||
candidate.close()
|
||||
last_error = OSError(error_code, os.strerror(error_code))
|
||||
if winner is not None:
|
||||
break
|
||||
if not active and pending:
|
||||
next_launch = time.monotonic()
|
||||
|
||||
if winner is None:
|
||||
raise last_error if last_error is not None else OSError(f"Could not connect to {host}:{port}")
|
||||
_quiet_unregister(selector, winner)
|
||||
active.discard(winner)
|
||||
winner.settimeout(timeout)
|
||||
for option in socket_options or ():
|
||||
winner.setsockopt(*option)
|
||||
winner.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
return winner
|
||||
finally:
|
||||
for candidate in active:
|
||||
_quiet_unregister(selector, candidate)
|
||||
candidate.close()
|
||||
selector.close()
|
||||
|
||||
|
||||
def _patch_urllib3_create_connection(module) -> None:
|
||||
"""Point ``urllib3.util.connection.create_connection`` (its own serial walker) at the racer."""
|
||||
if getattr(module.create_connection, "_hermes_happy_eyeballs", False):
|
||||
return
|
||||
urllib3_original = module.create_connection
|
||||
urllib3_sentinel = module._DEFAULT_TIMEOUT
|
||||
|
||||
def _urllib3_racer(address, timeout=urllib3_sentinel, source_address=None, socket_options=None):
|
||||
effective = socket.getdefaulttimeout() if timeout is urllib3_sentinel else timeout
|
||||
try:
|
||||
return _happy_eyeballs_create_connection(
|
||||
address, effective, source_address=source_address, socket_options=tuple(socket_options or ()))
|
||||
except OSError:
|
||||
raise
|
||||
except Exception:
|
||||
return urllib3_original(address, timeout, source_address=source_address, socket_options=socket_options)
|
||||
|
||||
_urllib3_racer._hermes_happy_eyeballs = True # type: ignore[attr-defined]
|
||||
module.create_connection = _urllib3_racer
|
||||
|
||||
|
||||
class _Urllib3ConnectionPatcher(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
||||
"""One-shot import hook: patch urllib3's connect walker the moment the module loads.
|
||||
|
||||
Importing urllib3 eagerly costs ~50 ms on every CLI start, and ``hermes`` / the TUI
|
||||
gateway never load it unless something actually calls ``requests``.
|
||||
"""
|
||||
|
||||
def find_spec(self, fullname, path, target=None):
|
||||
if fullname != _URLLIB3_CONNECTION_MODULE:
|
||||
return None
|
||||
if self in sys.meta_path:
|
||||
sys.meta_path.remove(self)
|
||||
spec = importlib.util.find_spec(fullname)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
self._inner = spec.loader
|
||||
spec.loader = self
|
||||
return spec
|
||||
|
||||
def create_module(self, spec):
|
||||
return self._inner.create_module(spec)
|
||||
|
||||
def exec_module(self, module):
|
||||
self._inner.exec_module(module)
|
||||
_patch_urllib3_create_connection(module)
|
||||
|
||||
|
||||
def install_happy_eyeballs_socket_connect() -> None:
|
||||
"""Race IPv6/IPv4 for every sync TCP connect in the process (RFC 8305, #114265).
|
||||
|
||||
The startup path does not build its HTTP clients in one place: the model catalog
|
||||
fetch goes through ``requests``/``urllib3``, sync LLM and OAuth clients through
|
||||
httpcore, plugins through ``urllib``/``http.client``. All of them funnel their TCP
|
||||
connect into ``socket.create_connection`` (``http.client`` re-reads it per connection;
|
||||
httpcore looks it up at call time) or into urllib3's own serial copy in
|
||||
``urllib3.util.connection``. The stock implementations walk the ``getaddrinfo``
|
||||
results serially — on a network whose advertised IPv6 route is blackholed, each AAAA
|
||||
record burns the full connect timeout before IPv4 answers. Idempotent, best-effort.
|
||||
"""
|
||||
if getattr(socket.create_connection, "_hermes_happy_eyeballs", False):
|
||||
return
|
||||
socket_original = socket.create_connection
|
||||
|
||||
def _socket_racer(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, source_address=None, *, all_errors=False):
|
||||
# Stock create_connection leaves the sentinel alone, so the socket keeps the
|
||||
# process default from socket.setdefaulttimeout(); the racer re-applies the
|
||||
# timeout on the winner, so it must resolve the sentinel the same way.
|
||||
effective = socket.getdefaulttimeout() if timeout is socket._GLOBAL_DEFAULT_TIMEOUT else timeout
|
||||
try:
|
||||
return _happy_eyeballs_create_connection(address, effective, source_address=source_address)
|
||||
except OSError:
|
||||
raise # every candidate failed — identical semantics to the serial original
|
||||
except Exception:
|
||||
return socket_original(address, timeout, source_address=source_address, all_errors=all_errors)
|
||||
|
||||
_socket_racer._hermes_happy_eyeballs = True # type: ignore[attr-defined]
|
||||
socket.create_connection = _socket_racer
|
||||
|
||||
urllib3_connection = sys.modules.get(_URLLIB3_CONNECTION_MODULE)
|
||||
if urllib3_connection is not None:
|
||||
_patch_urllib3_create_connection(urllib3_connection)
|
||||
elif not any(isinstance(finder, _Urllib3ConnectionPatcher) for finder in sys.meta_path):
|
||||
sys.meta_path.insert(0, _Urllib3ConnectionPatcher())
|
||||
|
||||
|
||||
def apply_windows_utf8_bootstrap() -> bool:
|
||||
@@ -117,3 +331,4 @@ def activate_durable_lazy_target() -> None:
|
||||
apply_windows_utf8_bootstrap()
|
||||
suppress_platform_ver_console()
|
||||
activate_durable_lazy_target()
|
||||
install_happy_eyeballs_socket_connect()
|
||||
|
||||
Reference in New Issue
Block a user