diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index f22fa97c0..65b816e5c 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -640,10 +640,20 @@ class BaseNode: async def telnet_forward(telnet_reader): - while not telnet_reader.at_eof(): - data = await telnet_reader.read(1024) - if data: - await websocket.send_bytes(data) + try: + while not telnet_reader.at_eof(): + data = await telnet_reader.read(1024) + if data: + await websocket.send_bytes(data) + except WebSocketDisconnect: + # the client disconnected while node output was still streaming: + # normal end of the session, not an error. Starlette raises + # WebSocketDisconnect (whose str() is empty) from send once the + # peer is gone, which used to surface as a message-less warning. + log.info( + f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute" + f" console WebSocket while node output was being forwarded" + ) # keep forwarding websocket data in both direction if sys.version_info >= (3, 11, 0): @@ -657,7 +667,7 @@ class BaseNode: if task.exception(): log.warning( f"Exception while forwarding WebSocket data to " - f"{self._console_type.upper()} server: {task.exception()}" + f"{self._console_type.upper()} server: {task.exception()!r}" ) for task in pending: task.cancel() @@ -728,8 +738,16 @@ class BaseNode: data = await vnc_reader.read(65536) # Larger buffer for VNC frames if data: await websocket.send_bytes(data) + except WebSocketDisconnect: + # the browser disconnected while VNC frames were still streaming + # (starlette raises WebSocketDisconnect with an empty str() from + # send once the peer is gone — not an error) + log.info( + f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute " + f"VNC console WebSocket while frames were being forwarded" + ) except Exception as e: - log.warning(f"Exception while forwarding VNC data to WebSocket: {e}") + log.warning(f"Exception while forwarding VNC data to WebSocket: {e!r}") # Keep forwarding WebSocket data in both directions if sys.version_info >= (3, 11, 0): @@ -741,7 +759,7 @@ class BaseNode: done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) for task in done: if task.exception(): - log.warning(f"Exception while forwarding WebSocket data to VNC server: {task.exception()}") + log.warning(f"Exception while forwarding WebSocket data to VNC server: {task.exception()!r}") for task in pending: task.cancel() diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index 9aa97cf81..7eca945e1 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -453,3 +453,57 @@ async def test_stop_ubridge_clears_marker_bridges(compute_project, manager): node._marker_filter_bridges["m", "L1"] = "VPCS-10" await node._stop_ubridge() assert node._marker_filter_bridges == {} + + +class _GoneConsoleWebsocket: + """ + Stand-in for the compute-side WebSocket when the peer (the controller, or a + browser) is already gone: receive() yields the disconnect message and + send_bytes raises WebSocketDisconnect — what starlette raises from send + after the transport reports OSError. + """ + + def __init__(self): + from types import SimpleNamespace + self.client = SimpleNamespace(host="127.0.0.1", port=5000) + + async def receive(self): + return {"type": "websocket.disconnect"} + + async def send_bytes(self, data): + from starlette.websockets import WebSocketDisconnect + raise WebSocketDisconnect(code=1006) + + +@pytest.mark.asyncio +async def test_console_websocket_client_disconnect_while_node_output_streams( + compute_project, manager, port_manager, monkeypatch, caplog): + # regression test: the client disconnects while the node is still streaming + # console output. telnet_forward used to let the (empty-str) WebSocketDisconnect + # from send_bytes escape, logging a message-less WARNING. + import asyncio + import logging + + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node.status = "started" + + telnet_reader = MagicMock() + telnet_reader.at_eof.return_value = False + telnet_reader.read = AsyncioMagicMock(return_value=b"device output") + telnet_writer = MagicMock() + telnet_writer.wait_closed = AsyncioMagicMock() + + async def fake_open_connection(*args, **kwargs): + return telnet_reader, telnet_writer + + monkeypatch.setattr(asyncio, "open_connection", fake_open_connection) + + with caplog.at_level(logging.INFO): + await node.start_websocket_console(_GoneConsoleWebsocket()) + + assert telnet_writer.close.called + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + assert any( + "has disconnected from compute console WebSocket while node output" in r.message + for r in caplog.records + )