From a8c96dd3f72ddae29c41bb0e1ef0545a000b0b51 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 6 Sep 2026 00:15:43 +0800 Subject: [PATCH] fix: keep port_number in Docker NIO dispatch of the batch endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project-open bulk path (_add_nio_binding / _get_existing_nio / _update_nio_binding in routes/compute/projects.py) dropped port_number for Docker nodes, unlike the per-node routes and the IOU branch. With multi-port docker adapters (iol-runner nodes model 4 ports per adapter, 0ca9ccc63) every batched NIO landed on port 0 where Adapter.add_nio() silently overwrites — the last entry per node won — so reopening a project clobbered the port-0 links with the port-1 NIOs: links ended up cross-wired between the wrong node pairs and the real links died (IOL direct-link ping failures after close/reopen, EXCESSCOLL storms from the phantom loops). --- gns3server/api/routes/compute/projects.py | 18 ++++++++++--- tests/api/routes/compute/test_projects.py | 31 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/gns3server/api/routes/compute/projects.py b/gns3server/api/routes/compute/projects.py index 7da980a08..54d7e460e 100644 --- a/gns3server/api/routes/compute/projects.py +++ b/gns3server/api/routes/compute/projects.py @@ -144,8 +144,14 @@ async def _add_nio_binding(node, adapter_number, port_number, nio): manager_name = type(node.manager).__name__ # Adapter-based nodes: docker / qemu / vmware / virtualbox take - # (adapter_number, nio); iou additionally takes port_number. - if manager_name in ("Docker", "Qemu", "VMware", "VirtualBox"): + # (adapter_number, nio); iou additionally takes port_number. Docker + # adapters can be multi-port (e.g. iol-runner nodes model 4 ports per + # adapter): dropping port_number would bind every NIO to port 0, where + # add_nio() silently overwrites — the last entry per node wins and links + # end up cross-wired (observed as dead direct links after reopen). + if manager_name == "Docker": + await node.adapter_add_nio_binding(adapter_number, nio, port_number) + elif manager_name in ("Qemu", "VMware", "VirtualBox"): await node.adapter_add_nio_binding(adapter_number, nio) elif manager_name == "IOU": await node.adapter_add_nio_binding(adapter_number, port_number, nio) @@ -176,7 +182,9 @@ def _get_existing_nio(node, adapter_number, port_number): """ manager_name = type(node.manager).__name__ - if manager_name in ("Docker", "Qemu", "VMware", "VirtualBox"): + if manager_name == "Docker": + return node.get_nio(adapter_number, port_number) + elif manager_name in ("Qemu", "VMware", "VirtualBox"): return node.get_nio(adapter_number) elif manager_name == "IOU": return node.get_nio(adapter_number, port_number) @@ -207,7 +215,9 @@ async def _update_nio_binding(node, adapter_number, port_number, nio): """ manager_name = type(node.manager).__name__ - if manager_name in ("Docker", "Qemu", "VMware", "VirtualBox"): + if manager_name == "Docker": + await node.adapter_update_nio_binding(adapter_number, nio, port_number) + elif manager_name in ("Qemu", "VMware", "VirtualBox"): await node.adapter_update_nio_binding(adapter_number, nio) elif manager_name == "IOU": await node.adapter_update_nio_binding(adapter_number, port_number, nio) diff --git a/tests/api/routes/compute/test_projects.py b/tests/api/routes/compute/test_projects.py index 949c0d891..6131db742 100644 --- a/tests/api/routes/compute/test_projects.py +++ b/tests/api/routes/compute/test_projects.py @@ -315,6 +315,37 @@ class TestBatchNIOEdgeCases: await _add_nio_binding(node, 1, 2, nio) node.adapter_add_nio_binding.assert_called_once_with(1, 2, nio) + @pytest.mark.asyncio + async def test_docker_dispatch_keeps_port_number(self): + """ + Docker adapters can be multi-port (iol-runner nodes model 4 ports per + adapter). Dropping port_number binds every NIO to port 0 where + add_nio() silently overwrites — reopened projects then end up with + cross-wired links (the last entry per node wins). + """ + from unittest.mock import AsyncMock, MagicMock + from gns3server.api.routes.compute.projects import ( + _add_nio_binding, + _get_existing_nio, + _update_nio_binding, + ) + + node = MagicMock() + type(node.manager).__name__ = "Docker" + node.adapter_add_nio_binding = AsyncMock() + node.adapter_update_nio_binding = AsyncMock() + nio = MagicMock() + node.get_nio = MagicMock(return_value=nio) + + await _add_nio_binding(node, 0, 1, nio) + node.adapter_add_nio_binding.assert_called_once_with(0, nio, 1) + + assert _get_existing_nio(node, 0, 1) is nio + node.get_nio.assert_called_once_with(0, 1) + + await _update_nio_binding(node, 0, 1, nio) + node.adapter_update_nio_binding.assert_called_once_with(0, nio, 1) + @pytest.mark.asyncio async def test_vpcs_dispatch_to_port_add_nio_binding(self): """_add_nio_binding dispatches VPCS to port_add_nio_binding."""