From 48dbdbe4a96dd89e0f83d4da8ccd73c7f9a7220d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 30 Sep 2026 01:37:36 +0800 Subject: [PATCH] fix(docker): delete the per-link kernel bridge when a node stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge teardown only hung off the link-removal path, but deleting a node or closing a project never issues a per-link delete — the veths disappeared with their containers and left the bridge behind as an empty orphan (reproduced: create a kernel link, DELETE the project, the gns3{link_id} bridge survives). `_remove_kernel_veths` now deletes the node's kernel-NIO bridges alongside the veth host ends: both endpoints do it, the peer's still-enslaved port answers EBUSY (suppressed, last one wins) exactly like link deletion, a relay NIO's uBridge bridge is left alone (it dies with the process), and a node restart rebuilds the bridge from the NIO in _connect_nio. Found while cleaning up the window_drop e2e: seven such bridges had accumulated on this host. Re-verified live: the bridge is gone after the project delete, and the window_drop e2e (which includes a node restart with the kernel link attached) still passes 15/15. --- docs/features/docker-kernel-datapath.md | 8 +++++- gns3server/compute/docker/docker_vm.py | 13 +++++++++ .../docker/test_docker_kernel_datapath.py | 28 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md index 426485902..fe7840197 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -151,7 +151,13 @@ endpoints became eligible are upgraded to the kernel datapath automatically. attached to the host end*. * **Death (container stop)** — `_remove_kernel_veths` deletes the host ends explicitly: unlike a TAP (which died with the container netns), a veth host - end outlives the container. Deleting either end destroys the pair. + end outlives the container. Deleting either end destroys the pair. The + node's per-link kernel bridges go with them — deleting a node or closing a + project never runs the link-teardown path (no per-link delete is issued), + so a bridge whose ports just disappeared would stay behind as an empty + orphan. Both endpoints do this and the peer's still-enslaved port makes + the delete fail with EBUSY (suppressed, last one wins), the same contract + as link deletion; a node restart rebuilds the bridge from the NIO. ## Link operations diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 0dd0dadd4..7e8384b51 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -1883,12 +1883,25 @@ class DockerVM(BaseNode): with the container network namespace) the host end outlives the container and must be removed explicitly. Deleting an enslaved veth detaches it from its bridge automatically. + + The per-link kernel bridges go too: deleting a node (or closing a + project) never runs the link-teardown path, so a bridge whose ports + just disappeared would stay behind as an empty orphan. Both link + endpoints run this — the peer's port still enslaved makes the delete + fail with EBUSY (suppressed) and the last one wins, the same + contract as _remove_kernel_nio. A node restart re-creates the bridge + from the NIO in _connect_nio, so nothing is lost by deleting it. """ if self.ubridge: for host_ifc in self._kernel_veths.values(): with contextlib.suppress(UbridgeError): await self._ubridge_send(f'docker delete_veth "{host_ifc}"') + for adapter in self._ethernet_adapters: + for nio in adapter.ports.values(): + if isinstance(nio, NIOBridge): + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delete "{nio.bridge}"') self._kernel_veths.clear() async def _get_namespace(self): diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index 4f3001e33..abb86049b 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -1006,6 +1006,34 @@ async def test_remove_kernel_veths(vm): assert vm._kernel_veths == {} +@pytest.mark.asyncio +async def test_remove_kernel_veths_deletes_per_link_bridge(vm): + """ + The per-link kernel bridge dies with the node too: deleting a project + never runs the link-teardown path, so without this the bridge stays + behind as an empty orphan (EBUSY from the peer's port is suppressed — + the last endpoint wins). + """ + + vm._ubridge_hypervisor = MagicMock() + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + # a relay NIO's bridge is not this node's to delete (uBridge dies with it) + relay = vm.manager.create_nio({"type": "nio_udp", "lport": 10000, "rhost": "127.0.0.1", "rport": 10001}) + vm._ethernet_adapters[0].add_nio(1, relay) + + await vm._remove_kernel_veths() + + vm._ubridge_send.assert_any_call(f'docker delete_veth "{host_ifc}"') + # exactly the kernel bridge — the relay NIO's uBridge bridge dies with + # the uBridge process and must not be touched here + deletes = [call.args[0] for call in vm._ubridge_send.call_args_list if "brctl delete" in call.args[0]] + assert deletes == [f'brctl delete "{BRIDGE}"'] + + def test_veth_names_deterministic_and_bounded(vm): host_ifc, guest_ifc = vm._veth_names(3, 2)