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): async def ws_receive(ws_console_compute):
""" """
Receive WebSocket data from client and forward to compute console WebSocket. 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: try:
while True: while True:
data = await websocket.receive_text() msg = await websocket.receive()
if data: if msg["type"] == "websocket.disconnect":
await ws_console_compute.send_str(data) 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: except WebSocketDisconnect:
await ws_console_compute.close() await ws_console_compute.close()
log.info( log.info(

View File

@ -19,6 +19,9 @@ import os
import stat import stat
import shutil import shutil
import asyncio import asyncio
import contextlib
import json
import struct
import tempfile import tempfile
import psutil import psutil
import platform import platform
@ -561,6 +564,51 @@ class BaseNode:
log.warning(f"Cannot connect to node {self.name} console server: {e}") log.warning(f"Cannot connect to node {self.name} console server: {e}")
return 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): async def ws_forward(telnet_writer):
try: try:
@ -571,6 +619,14 @@ class BaseNode:
if "text" in msg and msg["text"]: if "text" in msg and msg["text"]:
data = msg["text"].encode() data = msg["text"].encode()
elif "bytes" in msg and msg["bytes"]: 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"] data = msg["bytes"]
else: else:
continue continue

View File

@ -412,6 +412,17 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
return False return False
return True 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): async def _on_naws(self, columns, rows):
if self._exec_id: if self._exec_id:
try: try:
@ -509,8 +520,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
# (e.g. the IOS-XR pager) park at --More-- for clients # (e.g. the IOS-XR pager) park at --More-- for clients
# that never negotiate NAWS (netmiko, bare telnet). # that never negotiate NAWS (netmiko, bare telnet).
# Width 511 matches netmiko's 'terminal width 511'. # Width 511 matches netmiko's 'terminal width 511'.
# Real NAWS clients resize to their own geometry right # WebUI clients resize to their real geometry right after
# after connecting. # connecting, via WS terminal-size control frames turned
# into NAWS by start_websocket_console.
await self._on_naws(511, 10000) await self._on_naws(511, 10000)
except Exception: except Exception:
pass pass