From 65e8eb9e286cbe317780e415b406c88414545cce Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 20 Aug 2026 13:42:46 +0800 Subject: [PATCH] docker: don't lose a client size that races the exec creation A browser's terminal-size control frame (NAWS through the console telnet server) can arrive while client_connected_hook is still creating the exec; the resize is a no-op then, and the tall default applied after creation would overwrite it, leaving the session at 511x10000 until the user resizes. Record sizes received before the exec exists and prefer them over the tall default once creation finishes. The recorded size is cleared when the last client disconnects, together with the restore-to-default. --- gns3server/compute/docker/vendor_docker_vm.py | 36 ++++++++++++------- tests/compute/docker/test_vendor_docker_vm.py | 18 ++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index ca4b10489..13e6e0566 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -413,6 +413,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): self._command = command self._allow_resize = allow_resize self._exec_id = None + self._client_size = None # size received while no exec existed yet self._broadcast_task = None self._lock = asyncio.Lock() self._log_name = f"docker_exec console '{vm.name}'" @@ -436,18 +437,24 @@ 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): + self._client_size = None await self._resize_exec(511, 10000) async def _resize_exec(self, columns, rows): - if self._exec_id: - try: - await self._manager.query( - "POST", - f"exec/{self._exec_id}/resize", - params={"h": str(rows), "w": str(columns)}, - ) - except DockerError: - pass + if not self._exec_id: + # No exec yet (first client still inside client_connected_hook): + # remember the size — the hook applies it right after creation + # instead of the tall default, so it doesn't get overwritten. + self._client_size = (columns, rows) + return + try: + await self._manager.query( + "POST", + f"exec/{self._exec_id}/resize", + params={"h": str(rows), "w": str(columns)}, + ) + except DockerError: + pass async def _on_naws(self, columns, rows): # Client-driven resize (WS terminal-size control frames, telnet NAWS). @@ -544,10 +551,13 @@ 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'. - # 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._resize_exec(511, 10000) + # A size already pushed by this client (WS terminal-size + # control frames -> NAWS, racing the exec creation) wins + # over the default. + if self._client_size: + await self._resize_exec(*self._client_size) + else: + 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 753c3df91..e70bc6dd0 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -584,6 +584,24 @@ async def test_last_client_disconnect_restores_tall_default(compute_project, man srv._resize_exec.assert_called_once_with(511, 10000) +@pytest.mark.asyncio +async def test_size_arriving_before_exec_wins_over_default(compute_project, manager): + """A client size that races the exec creation (WS control frame / NAWS + arriving inside client_connected_hook) must not be overwritten by the + tall default once the exec exists.""" + + srv = _make_lazy_server(compute_project, manager) + assert srv._exec_id is None + # real _resize_exec (not the mock) records the size when no exec exists + srv._resize_exec = _LazyExecTelnetServer._resize_exec.__get__(srv) + await srv._on_naws(120, 40) + assert srv._client_size == (120, 40) + + srv._resize_exec = AsyncioMagicMock() + await srv.client_connected_hook() + srv._resize_exec.assert_called_once_with(120, 40) + + @pytest.mark.asyncio async def test_reconnect_live_exec_not_recreated(compute_project, manager): """Reconnecting while the exec is alive must NOT recreate it."""