From 5741e85b65d806649e2a67896248defa93d5a038 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 20 Aug 2026 13:36:55 +0800 Subject: [PATCH] docker: add GNS3_CONSOLE_RESIZE knob for paging CLIs The exec behind a docker_exec console is shared by every console client, so a browser's terminal-size resize (WS control frames -> NAWS) also changes the geometry concurrent netmiko sessions see. SR Linux doesn't care (no pager, no hard wrapping), but CLIs that page on the PTY window size (IOS-XR) would park at --More-- again the moment a browser is connected. Split the client-driven NAWS path (_on_naws) from the internal resize (_resize_exec): GNS3_CONSOLE_RESIZE=0 makes the console ignore client resizes entirely and keep the tall 511x10000 no-paging default, while the creation-time default and the restore-on-last-disconnect still go through the internal path. XRd appliance templates should set it. --- gns3server/compute/docker/vendor_docker_vm.py | 34 ++++++++++-- tests/compute/docker/test_vendor_docker_vm.py | 52 +++++++++++++++++-- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 46c56ca6f..ca4b10489 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -55,6 +55,11 @@ class VendorDockerVM(DockerVM): (adapter order) instead of default ``eth{N}``. * ``GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli`` — command run inside the container by the ``docker_exec`` console (defaults to ``/bin/sh``). + * ``GNS3_CONSOLE_RESIZE=0`` — ignore client-driven console resizes + (WS terminal-size frames / telnet NAWS). Set for CLIs that page on the + PTY window size (IOS-XR): the exec PTY must stay at the tall + no-NAWS default for every client, including concurrent netmiko + sessions on the shared exec. * ``GNS3_STOP_TIMEOUT=60`` — SIGTERM grace period in seconds when stopping the container (default 60; Docker SIGKILLs once it expires). """ @@ -77,6 +82,7 @@ class VendorDockerVM(DockerVM): self._gns3_init = True self._interface_names = [] self._console_cmd = None + self._console_resize = True self._stop_timeout = 60 if self._environment: for _line in self._environment.splitlines(): @@ -89,6 +95,8 @@ class VendorDockerVM(DockerVM): ] elif _line.startswith("GNS3_CONSOLE_CMD="): self._console_cmd = _line.split("=", 1)[1].strip() + elif _line.startswith("GNS3_CONSOLE_RESIZE="): + self._console_resize = _line.split("=", 1)[1].strip().lower() not in ("0", "false", "no") elif _line.startswith("GNS3_STOP_TIMEOUT="): try: timeout = int(_line.split("=", 1)[1].strip()) @@ -357,7 +365,13 @@ class VendorDockerVM(DockerVM): Command from GNS3_CONSOLE_CMD. """ - telnet = _LazyExecTelnetServer(self, self.manager, self._cid, self._console_cmd or "/bin/sh") + telnet = _LazyExecTelnetServer( + self, + self.manager, + self._cid, + self._console_cmd or "/bin/sh", + allow_resize=self._console_resize, + ) try: self._telnet_servers.append( await telnet.start(self._manager.port_manager.console_host, self.console) @@ -384,7 +398,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): CPR, producing a blank/degraded screen on reconnect. """ - def __init__(self, vm, manager, cid, command): + def __init__(self, vm, manager, cid, command, allow_resize=True): super().__init__( reader=None, writer=None, @@ -397,6 +411,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): self._manager = manager self._cid = cid self._command = command + self._allow_resize = allow_resize self._exec_id = None self._broadcast_task = None self._lock = asyncio.Lock() @@ -421,9 +436,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): # 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) + await self._resize_exec(511, 10000) - async def _on_naws(self, columns, rows): + async def _resize_exec(self, columns, rows): if self._exec_id: try: await self._manager.query( @@ -434,6 +449,15 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): except DockerError: pass + async def _on_naws(self, columns, rows): + # Client-driven resize (WS terminal-size control frames, telnet NAWS). + # Ignored for paging CLIs (GNS3_CONSOLE_RESIZE=0): with the exec shared + # by all clients, one browser resize would break concurrent netmiko + # sessions that rely on the tall no-paging geometry. + if not self._allow_resize: + return + await self._resize_exec(columns, rows) + async def run(self, network_reader, network_writer): """Catch and log any exception that kills the client session.""" try: @@ -523,7 +547,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): # 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) + await self._resize_exec(511, 10000) except Exception: pass else: diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py index 691adf1dd..753c3df91 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -463,12 +463,15 @@ def test_cleanup_console_resources_no_writer(compute_project, manager): # _LazyExecTelnetServer — upstream aliveness + reconnect/recreate logic # --------------------------------------------------------------------------- -def _make_lazy_server(compute_project, manager): +def _make_lazy_server(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli"): """Build a _LazyExecTelnetServer with _create_exec mocked out (no docker).""" - vm = _make_vm(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli") - srv = _LazyExecTelnetServer(vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli") + vm = _make_vm(compute_project, manager, environment=environment) + srv = _LazyExecTelnetServer( + vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli", + allow_resize=vm._console_resize, + ) srv._create_exec = AsyncioMagicMock() - srv._on_naws = AsyncioMagicMock() + srv._resize_exec = AsyncioMagicMock() return srv @@ -539,7 +542,46 @@ async def test_first_connect_sets_tall_default_pty_geometry(compute_project, man srv = _make_lazy_server(compute_project, manager) await srv.client_connected_hook() - srv._on_naws.assert_called_once_with(511, 10000) + srv._resize_exec.assert_called_once_with(511, 10000) + + +@pytest.mark.asyncio +async def test_client_naws_resizes_exec_by_default(compute_project, manager): + """Client-driven NAWS (WS terminal-size frames) reaches the exec resize.""" + + srv = _make_lazy_server(compute_project, manager) + await srv._on_naws(120, 40) + srv._resize_exec.assert_called_once_with(120, 40) + + +@pytest.mark.asyncio +async def test_client_naws_ignored_when_resize_disabled(compute_project, manager): + """GNS3_CONSOLE_RESIZE=0: client resizes must not change the shared exec + geometry (paging CLIs need the tall default for concurrent netmiko).""" + + srv = _make_lazy_server( + compute_project, manager, + environment="GNS3_CONSOLE_RESIZE=0", + ) + assert srv._allow_resize is False + await srv._on_naws(120, 40) + srv._resize_exec.assert_not_called() + # the tall default is still applied at exec creation (internal path) + await srv.client_connected_hook() + srv._resize_exec.assert_called_once_with(511, 10000) + + +@pytest.mark.asyncio +async def test_last_client_disconnect_restores_tall_default(compute_project, manager): + """When the last console client leaves, the exec goes back to the tall + no-NAWS default so a later non-NAWS client (netmiko) doesn't inherit a + browser geometry and hit PTY-window paging.""" + + srv = _make_lazy_server(compute_project, manager) + srv._exec_id = "abc" + writer = AsyncioMagicMock() + await srv._disconnect_client(writer) + srv._resize_exec.assert_called_once_with(511, 10000) @pytest.mark.asyncio