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 from __future__ import annotations
import asyncio import asyncio
import errno
import ipaddress import ipaddress
import json import json
import socket import socket
@@ -538,14 +539,32 @@ class WebSocketChannel(BaseChannel):
# -- Server lifecycle and connection ingress --------------------------- # -- Server lifecycle and connection ingress ---------------------------
@staticmethod @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.""" """Return whether every bound socket still has a live listen capability."""
try: try:
sockets = server.sockets sockets = server.sockets
return bool(sockets) and server.is_serving() and all( return bool(sockets) and server.is_serving() and all(
sock.fileno() >= 0 cls._socket_is_accepting(sock) for sock in sockets
and bool(sock.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN))
for sock in sockets
) )
except OSError: except OSError:
return False return False
@@ -0,0 +1,86 @@
"""Regression tests for WebSocket listener health probing portability."""
from __future__ import annotations
import errno
import socket
from typing import Any
import pytest
from nanobot.channels.websocket.runtime import WebSocketChannel
class _StubSocket:
"""Minimal socket stand-in: real sockets forbid attribute patching."""
def __init__(self, *, fileno: int, error: OSError | None = None, value: int = 1):
self._fileno = fileno
self._error = error
self._value = value
def fileno(self) -> int:
return self._fileno
def getsockopt(self, *_args: Any, **_kwargs: Any) -> int:
if self._error is not None:
raise self._error
return self._value
@pytest.fixture
def listening_socket() -> socket.socket:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
sock.listen(1)
yield sock
sock.close()
def test_real_listening_socket_is_accepting(listening_socket: socket.socket) -> None:
"""A genuinely listening socket must never be reported as degraded.
On macOS/BSD this exercises the ``ENOPROTOOPT`` fallback path; on Linux it
exercises the native ``SO_ACCEPTCONN`` path. Both must agree.
"""
assert WebSocketChannel._socket_is_accepting(listening_socket) is True
def test_closed_socket_is_not_accepting() -> None:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
sock.listen(1)
sock.close()
assert WebSocketChannel._socket_is_accepting(sock) is False
@pytest.mark.parametrize(
"unsupported_errno",
[errno.ENOPROTOOPT, errno.EOPNOTSUPP, errno.EINVAL],
)
def test_unsupported_sockopt_falls_back_to_fd_liveness(unsupported_errno: int) -> None:
"""macOS/BSD reject ``SO_ACCEPTCONN`` even on healthy listeners.
Treating that rejection as "not serving" made the listener look permanently
degraded, so the channel retried forever and never became ready.
"""
sock = _StubSocket(fileno=3, error=OSError(unsupported_errno, "Protocol not available"))
assert WebSocketChannel._socket_is_accepting(sock) is True
def test_unexpected_oserror_propagates() -> None:
sock = _StubSocket(fileno=3, error=OSError(errno.EBADF, "Bad file descriptor"))
with pytest.raises(OSError) as excinfo:
WebSocketChannel._socket_is_accepting(sock)
assert excinfo.value.errno == errno.EBADF
def test_unsupported_sockopt_still_rejects_dead_fd() -> None:
"""The portability fallback must not mask an already-closed listener."""
sock = _StubSocket(fileno=-1, error=OSError(errno.ENOPROTOOPT, "Protocol not available"))
assert WebSocketChannel._socket_is_accepting(sock) is False