fix(docker): delete the per-link kernel bridge when a node stops

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.
This commit is contained in:
YueGuobin 2026-09-30 01:37:36 +08:00
parent 316e7002c1
commit 48dbdbe4a9
No known key found for this signature in database
3 changed files with 48 additions and 1 deletions

View File

@ -151,7 +151,13 @@ endpoints became eligible are upgraded to the kernel datapath automatically.
attached to the host end*. attached to the host end*.
* **Death (container stop)** — `_remove_kernel_veths` deletes the host ends * **Death (container stop)** — `_remove_kernel_veths` deletes the host ends
explicitly: unlike a TAP (which died with the container netns), a veth host 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 ## Link operations

View File

@ -1883,12 +1883,25 @@ class DockerVM(BaseNode):
with the container network namespace) the host end outlives the with the container network namespace) the host end outlives the
container and must be removed explicitly. Deleting an enslaved veth container and must be removed explicitly. Deleting an enslaved veth
detaches it from its bridge automatically. 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: if self.ubridge:
for host_ifc in self._kernel_veths.values(): for host_ifc in self._kernel_veths.values():
with contextlib.suppress(UbridgeError): with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'docker delete_veth "{host_ifc}"') 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() self._kernel_veths.clear()
async def _get_namespace(self): async def _get_namespace(self):

View File

@ -1006,6 +1006,34 @@ async def test_remove_kernel_veths(vm):
assert vm._kernel_veths == {} 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): def test_veth_names_deterministic_and_bounded(vm):
host_ifc, guest_ifc = vm._veth_names(3, 2) host_ifc, guest_ifc = vm._veth_names(3, 2)