console: forward client terminal size over the console WebSocket

The docker_exec console defaults its exec PTY to 511x10000 (the no-NAWS
default that keeps the IOS-XR pager quiet for netmiko). A CPR-answering
client (xterm.js) on top of that tall canvas makes prompt_toolkit-based
CLIs (SR Linux sr_cli) re-emit their accumulated output on every
incremental render: ~145 KB instead of ~60 KB per command, visible in
the WebUI as full-screen clear/redraw flicker.

Let WebSocket console clients propagate their real terminal geometry:
binary frames {"cols": N, "rows": M} alongside text frames carrying
terminal data. The controller forwards binary frames (previously only
text was forwarded), and the compute side turns them into a NAWS
subnegotiation for telnet-based consoles (docker_exec included) or an
asyncssh pty size change for SSH consoles.

The docker_exec console restores the tall 511x10000 default when its
last client disconnects, so a later non-NAWS client (netmiko, bare
telnet) connecting to the still-live exec doesn't inherit a browser
geometry and hit PTY-window paging again.
This commit is contained in:
YueGuobin 2026-08-20 13:30:13 +08:00
parent 5188ae625a
commit abd0b8e274
No known key found for this signature in database
3 changed files with 79 additions and 5 deletions

View File

@ -694,13 +694,19 @@ async def ws_console(
async def ws_receive(ws_console_compute):
"""
Receive WebSocket data from client and forward to compute console WebSocket.
Text frames carry terminal data; binary frames carry client control
messages (e.g. terminal size), forwarded as-is.
"""
try:
while True:
data = await websocket.receive_text()
if data:
await ws_console_compute.send_str(data)
msg = await websocket.receive()
if msg["type"] == "websocket.disconnect":
break
if "text" in msg and msg["text"]:
await ws_console_compute.send_str(msg["text"])
elif "bytes" in msg and msg["bytes"]:
await ws_console_compute.send_bytes(msg["bytes"])
except WebSocketDisconnect:
await ws_console_compute.close()
log.info(

View File

@ -19,6 +19,9 @@ import os
import stat
import shutil
import asyncio
import contextlib
import json
import struct
import tempfile
import psutil
import platform
@ -561,6 +564,51 @@ class BaseNode:
log.warning(f"Cannot connect to node {self.name} console server: {e}")
return
def _parse_terminal_size_message(data: bytes):
"""
Binary control frames sent by WebSocket console clients to propagate
their terminal geometry: {"cols": int, "rows": int}. Terminal data
travels as text frames (xterm.js AttachAddon), so binary frames are
an unambiguous side channel. Returns (cols, rows) or None.
"""
try:
message = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
return None
if not isinstance(message, dict):
return None
cols, rows = message.get("cols"), message.get("rows")
if (
isinstance(cols, int) and not isinstance(cols, bool)
and isinstance(rows, int) and not isinstance(rows, bool)
and 2 <= cols <= 5000
and 2 <= rows <= 100000
):
return cols, rows
return None
async def resize_console(cols: int, rows: int) -> None:
"""
Propagate a client terminal resize to the node console stream:
SSH channels use a pty request update, telnet-based consoles
(including docker_exec) speak a NAWS subnegotiation to the console
telnet server, which resizes the underlying stream (e.g. the
docker exec pty).
"""
if self._console_type == "ssh":
with contextlib.suppress(AttributeError):
ssh_process.change_terminal_size(cols, rows)
else:
telnet_writer.write(
bytes([255, 251, 31]) # IAC WILL NAWS
+ bytes([255, 250, 31]) # IAC SB NAWS
+ struct.pack("!HH", cols, rows).replace(b"\xff", b"\xff\xff")
+ bytes([255, 240]) # IAC SE
)
await telnet_writer.drain()
async def ws_forward(telnet_writer):
try:
@ -571,6 +619,14 @@ class BaseNode:
if "text" in msg and msg["text"]:
data = msg["text"].encode()
elif "bytes" in msg and msg["bytes"]:
size = _parse_terminal_size_message(msg["bytes"])
if size is not None:
log.debug(
f"Console WebSocket client {websocket.client.host}:{websocket.client.port}"
f" resized terminal to {size[0]}x{size[1]}"
)
await resize_console(*size)
continue
data = msg["bytes"]
else:
continue

View File

@ -412,6 +412,17 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
return False
return True
async def _disconnect_client(self, network_writer):
await super()._disconnect_client(network_writer)
# When the last client leaves, restore the tall no-NAWS default: a
# browser client resizes the exec to its own geometry (WS terminal
# size control frames -> NAWS), and the next non-NAWS client (netmiko,
# bare telnet) connecting to the still-live exec would otherwise
# inherit it and hit PTY-window paging (the IOS-XR --More-- trap).
if self._exec_id and not await self._get_connections_snapshot():
with contextlib.suppress(Exception):
await self._on_naws(511, 10000)
async def _on_naws(self, columns, rows):
if self._exec_id:
try:
@ -509,8 +520,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
# (e.g. the IOS-XR pager) park at --More-- for clients
# that never negotiate NAWS (netmiko, bare telnet).
# Width 511 matches netmiko's 'terminal width 511'.
# Real NAWS clients resize to their own geometry right
# after connecting.
# WebUI clients resize to their real geometry right after
# connecting, via WS terminal-size control frames turned
# into NAWS by start_websocket_console.
await self._on_naws(511, 10000)
except Exception:
pass