From f5bdc91c07de60f83d68a3833721e65f89ba226d Mon Sep 17 00:00:00 2001 From: Paulo Date: Mon, 28 Sep 2026 10:27:04 +0200 Subject: [PATCH] Treat a Chat bridge channel that ends before any answer as unavailable A docker-sbx gateway accepts a channel to a closed loopback port and then ends it, so the client reads an empty line instead of getting ChannelOpenError. Bridge.request now raises ChatBridgeUnavailable for both signals, and Bridge.start uploads and starts the bridge on such a host. --- backend/druks/chat/bridge.py | 7 +++--- backend/tests/test_chat.py | 44 ++++++++++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/backend/druks/chat/bridge.py b/backend/druks/chat/bridge.py index 714aef55..56b97ddf 100644 --- a/backend/druks/chat/bridge.py +++ b/backend/druks/chat/bridge.py @@ -31,12 +31,13 @@ async def request(self, method: str, **values: object) -> dict: finally: writer.close() await writer.wait_closed() + # A docker-sbx gateway accepts a channel to a closed port and then ends it. + if not line: + raise ChatBridgeUnavailable("The Chat bridge is not available.") try: response = json.loads(line) except ValueError as error: - raise ChatBridgeError( - "The Chat bridge closed the request without an answer." - ) from error + raise ChatBridgeError("The Chat bridge sent an answer that is not JSON.") from error if response["ok"]: return response raise ChatBridgeError(response["error"]) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 5bed63cd..b21aae75 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -4,9 +4,10 @@ from contextlib import asynccontextmanager from datetime import timedelta from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 +import asyncssh import pytest from druks.accounts.enums import AccountKind from druks.accounts.models import Account, PersonalAccessToken @@ -14,7 +15,12 @@ from druks.chat.bridge import Bridge from druks.chat.constants import CHAT_KEY_NAME from druks.chat.enums import ConversationSource, MessageRole, MessageState -from druks.chat.exceptions import ChatBridgeError, ChatHarnessError, ChatSandboxGone +from druks.chat.exceptions import ( + ChatBridgeError, + ChatBridgeUnavailable, + ChatHarnessError, + ChatSandboxGone, +) from druks.chat.models import Conversation, Message from druks.database import get_session from druks.files.datastructures import File @@ -354,14 +360,42 @@ async def download(**values): assert host.upload_file.await_count == 0 -async def test_bridge_start_uploads_the_bridge_only_when_none_answers(monkeypatch): +def channel(answer: bytes) -> tuple[asyncio.StreamReader, MagicMock]: + """A loopback channel that sends one answer and ends.""" + reader = asyncio.StreamReader() + reader.feed_data(answer) + reader.feed_eof() + return reader, MagicMock(drain=AsyncMock(), wait_closed=AsyncMock()) + + +@pytest.mark.parametrize( + ("opened", "error"), + [ + ( + asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, "Connection refused"), + ChatBridgeUnavailable, + ), + (lambda *address: channel(b""), ChatBridgeUnavailable), + (lambda *address: channel(b"not json\n"), ChatBridgeError), + ], +) +async def test_bridge_is_unavailable_only_when_it_sends_no_answer(opened, error): + host = SimpleNamespace(open_tcp_connection=AsyncMock(side_effect=opened)) + + with pytest.raises(error) as raised: + await Bridge(host).request("ping") + + assert raised.type is error + + +async def test_bridge_start_uploads_the_bridge_only_when_none_answers(): + pong = b'{"ok": true}\n' host = SimpleNamespace( ssh_username="druks", + open_tcp_connection=AsyncMock(side_effect=[channel(b""), channel(pong), channel(pong)]), upload_file=AsyncMock(), exec=AsyncMock(return_value=SimpleNamespace(ok=True)), ) - answers = iter([False, True, True]) - monkeypatch.setattr(Bridge, "is_running", AsyncMock(side_effect=lambda: next(answers))) await Bridge(host).start() await Bridge(host).start()