From 47b676c601a0ad0627ad558b0312280833f223f8 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Tue, 7 Jul 2026 21:09:42 -0700 Subject: [PATCH 1/2] fix(websocket): answer non-GET HTTP probes instead of 502 + error flood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nanobot's gateway serves the WebUI through the websockets server, whose handshake parser only accepts GET. A HEAD probe (as a hosted load balancer or uptime monitor sends) made websockets abort the connection — the client got a 502 and a full traceback was logged at ERROR. On Render, something probes HEAD / roughly once per second, so the logs became a continuous wall of "opening handshake failed" tracebacks. Add a ServerConnection subclass that sniffs the first request line before the handshake is parsed: GET is handed to websockets unchanged (WS upgrades and every plain-HTTP WebUI route), HEAD gets an empty 200 so health checks stay green, and any other method gets 405. Answered probes are closed quietly, so nothing is logged at ERROR and the client never sees a 502. Wired via create_connection on serve()/unix_serve(). Co-Authored-By: Claude Opus 4.8 (1M context) --- nanobot/channels/websocket.py | 90 +++++++++++++++++ tests/channels/test_websocket_head_probe.py | 106 ++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 tests/channels/test_websocket_head_probe.py diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 536a94b1..0e5356d0 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -20,6 +20,7 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve from websockets.exceptions import ConnectionClosed from websockets.http11 import Request as WsRequest +from websockets.protocol import State from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage from nanobot.bus.outbound_events import ( @@ -338,6 +339,93 @@ def _is_websocket_upgrade(request: WsRequest) -> bool: return True +# HTTP methods recognized while sniffing the first request line of a connection. +_KNOWN_HTTP_METHODS = frozenset( + {b"GET", b"HEAD", b"POST", b"PUT", b"DELETE", b"OPTIONS", b"PATCH", b"TRACE", b"CONNECT"} +) +# Give up sniffing (and let websockets reject the connection itself) if a request +# line this long arrives without a CRLF. +_PROBE_SNIFF_MAX_BYTES = 8192 + + +class _WebUIServerConnection(ServerConnection): + """A ``ServerConnection`` that answers non-GET HTTP probes without erroring. + + The websockets handshake parser only accepts ``GET``. Any other method — e.g. + a ``HEAD`` health/uptime probe from a platform load balancer — makes it abort + the connection and log the failure at ERROR with a full traceback, and the + prober receives a ``502``. On a hosted deploy that probes once per second, + this floods the logs. + + We sniff the first request line before the handshake is parsed: ``GET`` is + handed to websockets unchanged (WebSocket upgrades and every plain-HTTP WebUI + route), ``HEAD`` gets an empty ``200`` so health checks stay green, and any + other method gets ``405``. Answered probes are closed quietly, so nothing is + logged at ERROR and the client never sees a ``502``. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._probe_buf = b"" + self._sniffing = True + self._probe_answered = False + + def data_received(self, data: bytes) -> None: + if not self._sniffing or self.protocol.state is not State.CONNECTING: + super().data_received(data) + return + self._probe_buf += data + newline = self._probe_buf.find(b"\r\n") + if newline == -1: + if len(self._probe_buf) >= _PROBE_SNIFF_MAX_BYTES: + self._flush_to_handshake() + return + method = self._probe_buf[:newline].split(b" ", 1)[0].upper() + if method == b"GET" or method not in _KNOWN_HTTP_METHODS: + # WebSocket upgrade / WebUI HTTP GET, or something we don't recognize: + # hand it to websockets to parse (and reject, if malformed) as usual. + self._flush_to_handshake() + return + self._answer_probe(method) + + def _flush_to_handshake(self) -> None: + buffered, self._probe_buf, self._sniffing = self._probe_buf, b"", False + super().data_received(buffered) + + def _answer_probe(self, method: bytes) -> None: + self._sniffing = False + self._probe_answered = True + if method == b"HEAD": + response = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + else: + body = b"Method Not Allowed\n" + response = ( + b"HTTP/1.1 405 Method Not Allowed\r\n" + b"Allow: GET\r\n" + b"Content-Type: text/plain; charset=utf-8\r\n" + b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n" + b"Connection: close\r\n\r\n" + body + ) + with suppress(Exception): + self.transport.write(response) + with suppress(Exception): + self.transport.close() + self.logger.debug( + "answered non-GET HTTP probe (" + method.decode("ascii", "replace") + ")" + ) + + async def handshake(self, *args: Any, **kwargs: Any) -> None: + try: + await super().handshake(*args, **kwargs) + except Exception: + # After answering a probe we close the socket ourselves; the base + # handshake then sees EOF-before-request and raises. That's expected, + # not an error, so swallow it and let conn_handler close quietly. + if self._probe_answered: + return + raise + + class WebSocketChannel(BaseChannel): """Run a local WebSocket server; forward text/JSON messages to the message bus.""" @@ -551,6 +639,7 @@ async def runner() -> None: server = await unix_serve( handler, socket_path, + create_connection=_WebUIServerConnection, process_request=process_request, open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S, max_size=self.config.max_message_bytes, @@ -565,6 +654,7 @@ async def runner() -> None: handler, self.config.host, self.config.port, + create_connection=_WebUIServerConnection, process_request=process_request, open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S, max_size=self.config.max_message_bytes, diff --git a/tests/channels/test_websocket_head_probe.py b/tests/channels/test_websocket_head_probe.py new file mode 100644 index 00000000..e97385e5 --- /dev/null +++ b/tests/channels/test_websocket_head_probe.py @@ -0,0 +1,106 @@ +"""The WebSocket channel must answer non-GET HTTP probes cleanly. + +A hosted deploy's load balancer / uptime monitor probes the public port. The +websockets handshake parser only accepts GET, so a HEAD probe used to abort the +connection (client sees 502) and log a full traceback at ERROR once per probe — +flooding the logs. The channel now sniffs the first request line: GET passes +through (WS upgrades + WebUI HTTP), HEAD gets an empty 200, other methods get +405, and probes are closed without an ERROR log. +""" + +import asyncio +import functools +import logging +import random +import socket +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig +from nanobot.webui.gateway_services import build_gateway_services + + +def _free_port() -> int: + for _ in range(100): + port = random.randint(30_000, 60_000) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("127.0.0.1", port)) + except OSError: + continue + return port + raise RuntimeError("could not find a free localhost port") + + +def _channel(bus: Any, port: int) -> WebSocketChannel: + cfg: dict[str, Any] = { + "enabled": True, + "allowFrom": ["*"], + "host": "127.0.0.1", + "port": port, + "path": "/", + "websocketRequiresToken": False, + } + config = WebSocketConfig.model_validate(cfg) + gateway = build_gateway_services( + config=config, + bus=bus, + session_manager=None, + static_dist_path=None, + workspace_path=None, + default_restrict_to_workspace=False, + runtime_model_name=None, + runtime_surface="browser", + runtime_capabilities_overrides=None, + ) + return WebSocketChannel(cfg, bus, gateway=gateway) + + +async def _request(method: str, url: str) -> httpx.Response: + return await asyncio.to_thread( + functools.partial(httpx.request, method, url, timeout=5.0, trust_env=False) + ) + + +@pytest.fixture() +def bus() -> MagicMock: + b = MagicMock() + b.publish_inbound = AsyncMock() + return b + + +@pytest.mark.asyncio +async def test_head_probe_returns_200_and_does_not_log_error( + bus: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + port = _free_port() + base = f"http://127.0.0.1:{port}" + channel = _channel(bus, port) + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + try: + with caplog.at_level(logging.ERROR, logger="websockets.server"): + # HEAD (what a health/uptime probe sends) is answered, not aborted. + head = await _request("HEAD", f"{base}/") + assert head.status_code == 200 + + # A GET WebUI route is unaffected by the sniffing. + boot = await _request("GET", f"{base}/webui/bootstrap") + assert boot.status_code == 200 + + # An unsupported method gets a clean 405, not a 502. + post = await _request("POST", f"{base}/") + assert post.status_code == 405 + assert "GET" in post.headers.get("allow", "") + + # The probe must not produce the websockets "opening handshake failed" + # ERROR traceback that used to flood the logs. + assert not any( + "opening handshake failed" in record.getMessage() for record in caplog.records + ) + finally: + await channel.stop() + await server_task From 1402c18df90455e4ab93ce9b9e805b671f6417d3 Mon Sep 17 00:00:00 2001 From: Ho1yShif Date: Tue, 7 Jul 2026 21:15:05 -0700 Subject: [PATCH 2/2] refactor(websocket): idiomatic probe logging + reuse handshake-message constant Use stdlib %s lazy formatting for the debug log (self.logger here is the stdlib "websockets.server" logger, not nanobot's loguru logger), and reuse the existing OPENING_HANDSHAKE_FAILED_MESSAGE constant in the test instead of hardcoding the literal. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- nanobot/channels/websocket.py | 4 +--- tests/channels/test_websocket_head_probe.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 0e5356d0..f43dd1ed 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -410,9 +410,7 @@ def _answer_probe(self, method: bytes) -> None: self.transport.write(response) with suppress(Exception): self.transport.close() - self.logger.debug( - "answered non-GET HTTP probe (" + method.decode("ascii", "replace") + ")" - ) + self.logger.debug("answered non-GET HTTP probe (%s)", method.decode("ascii", "replace")) async def handshake(self, *args: Any, **kwargs: Any) -> None: try: diff --git a/tests/channels/test_websocket_head_probe.py b/tests/channels/test_websocket_head_probe.py index e97385e5..b9891b76 100644 --- a/tests/channels/test_websocket_head_probe.py +++ b/tests/channels/test_websocket_head_probe.py @@ -21,6 +21,7 @@ from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig from nanobot.webui.gateway_services import build_gateway_services +from nanobot.webui.websocket_logging import OPENING_HANDSHAKE_FAILED_MESSAGE def _free_port() -> int: @@ -99,7 +100,8 @@ async def test_head_probe_returns_200_and_does_not_log_error( # The probe must not produce the websockets "opening handshake failed" # ERROR traceback that used to flood the logs. assert not any( - "opening handshake failed" in record.getMessage() for record in caplog.records + OPENING_HANDSHAKE_FAILED_MESSAGE in record.getMessage() + for record in caplog.records ) finally: await channel.stop()