From bd9b74e2c77207bf9d8103ae201cbcf6d793c46d Mon Sep 17 00:00:00 2001 From: Krislu1221 <258380416+Krislu1221@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:23:07 +0800 Subject: [PATCH] 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. --- nanobot/channels/websocket/runtime.py | 27 +++++- .../test_websocket_listener_health.py | 86 +++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 tests/channels/test_websocket_listener_health.py diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 92f5f12fe..7e54209ef 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -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 diff --git a/tests/channels/test_websocket_listener_health.py b/tests/channels/test_websocket_listener_health.py new file mode 100644 index 000000000..22b85e9c7 --- /dev/null +++ b/tests/channels/test_websocket_listener_health.py @@ -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