mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 16:51:53 +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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user