mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +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:
@@ -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