fix: handle client disconnect in compute console WebSocket forwarding

The compute-side telnet_forward (and vnc_forward) let the
WebSocketDisconnect that starlette raises from send_bytes once the peer
is gone escape to the generic task-exception handler. Its str() is
empty, so every client disconnect during active node output logged a
message-less warning:

  WARNING gns3server.compute.base_node:658 Exception while forwarding
  WebSocket data to TELNET server:

Catch WebSocketDisconnect in both forwarders and log it at INFO as the
normal end of a session, and format the remaining task exceptions with
!r so their type stays visible. Mirrors the controller-side fix for
the same scenario (2324dcd74).
This commit is contained in:
YueGuobin 2026-08-29 10:09:05 +08:00
parent f95d4c7a4e
commit 3cf9dbff5b
No known key found for this signature in database
2 changed files with 79 additions and 7 deletions

View File

@ -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()

View File

@ -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
)