mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
fix(websocket): stop treating SO_ACCEPTCONN as portable in listener health check
`_listener_is_serving()` probes `SO_ACCEPTCONN` to decide whether a bound
socket is still accepting connections. That option is not portable: macOS
and the BSDs raise `OSError` with `ENOPROTOOPT` ("Protocol not available")
for it even on a perfectly healthy listening socket.
The surrounding `except OSError: return False` swallows that error, so the
listener is reported as degraded on every check. `_serve_forever()` then
raises `_ListenerUnavailableError("WebSocket listener did not enter a
serving state")`, and because `_is_recoverable_listener_error()` classifies
that exception as recoverable, the channel retries forever (4s -> 8s -> 16s
-> 30s backoff) and never becomes ready. On macOS this leaves the gateway
permanently at `ready: false` with the WebUI unreachable, since the
WebSocket channel is enabled by default.
Reproduced on macOS 26.5.1 / Python 3.14.3:
>>> s = socket.socket(); s.bind(("127.0.0.1", 0)); s.listen(1)
>>> s.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN)
OSError: [Errno 42] Protocol not available
Extract `_socket_is_accepting()` and treat `ENOPROTOOPT` / `EOPNOTSUPP` /
`EINVAL` as "this platform cannot answer the question", falling back to the
file-descriptor liveness check that already guarded the expression. Other
`OSError`s still propagate, and a closed socket is still rejected via the
`fileno() < 0` short circuit, so Linux behaviour is unchanged.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
@@ -538,14 +539,32 @@ class WebSocketChannel(BaseChannel):
|
||||
# -- Server lifecycle and connection ingress ---------------------------
|
||||
|
||||
@staticmethod
|
||||
def _listener_is_serving(server: Server) -> bool:
|
||||
def _socket_is_accepting(sock: socket.socket) -> bool:
|
||||
"""Return whether a bound socket still advertises a listen capability.
|
||||
|
||||
``SO_ACCEPTCONN`` is not portable: macOS/BSD raise ``OSError`` with
|
||||
``ENOPROTOOPT`` ("Protocol not available") for this option even on a
|
||||
perfectly healthy listening socket. Treating that as "not serving"
|
||||
makes the listener look permanently degraded, so the caller retries
|
||||
forever and the channel never reaches a ready state. When the option
|
||||
is unavailable we fall back to the file-descriptor liveness check.
|
||||
"""
|
||||
if sock.fileno() < 0:
|
||||
return False
|
||||
try:
|
||||
return bool(sock.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN))
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.ENOPROTOOPT, errno.EOPNOTSUPP, errno.EINVAL):
|
||||
return True
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def _listener_is_serving(cls, server: Server) -> bool:
|
||||
"""Return whether every bound socket still has a live listen capability."""
|
||||
try:
|
||||
sockets = server.sockets
|
||||
return bool(sockets) and server.is_serving() and all(
|
||||
sock.fileno() >= 0
|
||||
and bool(sock.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN))
|
||||
for sock in sockets
|
||||
cls._socket_is_accepting(sock) for sock in sockets
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user