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