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:
Krislu1221
2026-09-01 11:14:34 +08:00
committed by Xubin Ren
parent 3c25f826ea
commit bd9b74e2c7
2 changed files with 109 additions and 4 deletions
+23 -4
View File
@@ -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