From 9e0d5050db7d00c1ca4050d30b034d2cec5ce7c1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 14 Oct 2026 21:52:34 +0800 Subject: [PATCH 1/3] feat: concurrent kernel captures, one per interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uBridge's capture module now serves one AF_PACKET capture per interface, any number per process, and `capture stop_kernel` takes the interface it stops — this rides that (breaking, paired-binary) protocol change. The server-side single-slot tracker (_kernel_capture_ifc with its reserve/release/owned_by helpers) is gone: same-port duplicates were already refused by the NIO capturing flag, uBridge answers EALREADY on a same-interface duplicate, and per-interface stops removed the process- wide stop the ownership gating guarded against. The docker e2e drives ten concurrent captures on one node's uBridge and asserts a per-interface stop freezes exactly the stopped half while the live half keeps recording. --- docs/features/docker-kernel-datapath.md | 2 +- gns3server/compute/base_node.py | 4 - .../compute/builtin/nodes/ethernet_switch.py | 15 +-- gns3server/compute/docker/docker_vm.py | 19 ++-- gns3server/compute/dynamips/nodes/router.py | 16 +-- gns3server/compute/iou/iou_vm.py | 16 +-- gns3server/compute/kernel_datapath.py | 51 +-------- gns3server/compute/qemu/qemu_vm.py | 15 +-- gns3server/compute/vpcs/vpcs_vm.py | 15 +-- .../compute/test_ethernet_switch_nodes.py | 2 +- .../docker/test_docker_kernel_datapath.py | 63 ++++------- .../dynamips/test_dynamips_kernel_datapath.py | 2 +- tests/compute/iou/test_iou_kernel_datapath.py | 2 +- .../compute/qemu/test_qemu_kernel_datapath.py | 2 +- tests/e2e/test_docker_kernel_datapath.py | 102 ++++++++++++++++++ 15 files changed, 155 insertions(+), 171 deletions(-) diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md index 64a2f6079..6e527dd99 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -243,7 +243,7 @@ The veth pairs already exist at attach time — every adapter is born as a veth ## Capture -Kernel links capture via uBridge's AF_PACKET module bound to the veth host end: `capture start_kernel "" [dlt]` / `capture stop_kernel`. Single capture per uBridge process (second concurrent → EALREADY): the server tracks which anchor owns the slot, refuses a second port's capture with a clear error instead of leaving it wedged, and only the owning port's stop issues the process-wide `capture stop_kernel`. Start/stop key on the **NIO type**, not on veth presence — a relay NIO riding a veth captures at its relay bridge. +Kernel links capture via uBridge's AF_PACKET module bound to the veth host end: `capture start_kernel "" [dlt]` / `capture stop_kernel `. One capture per interface, any number of interfaces concurrently — a node can capture several ports at once; a same-interface duplicate answers EALREADY, which the server's per-port `capturing` flag already refuses before uBridge sees it. Stops are per interface too: each port's stop names its own anchor, never touching a sibling port's capture. Start/stop key on the **NIO type**, not on veth presence — a relay NIO riding a veth captures at its relay bridge. ## Markers diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 9c53bb0aa..0913e1758 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1057,10 +1057,6 @@ class BaseNode: # than skipping them as "already installed". self._marker_filter_bridges.clear() self._marker_specs.clear() - # ...and so is the kernel capture, uBridge's single AF_PACKET slot - # (meaningful for the kernel-datapath node types; see - # KernelDatapathMixin._reserve_kernel_capture). - self._kernel_capture_ifc = None async def add_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio): """ diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index 6c11a2238..5989ccc7c 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -768,14 +768,11 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): anchor = self._kernel_ports.get(port_number) if anchor is not None: - # One capture per uBridge process — see - # _reserve_kernel_capture; a failed start rolls the port's - # flag back so it cannot later stop the winner's capture. + # One capture per interface; a failed start rolls the port's + # flag back. try: - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise else: @@ -797,12 +794,8 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): anchor = self._kernel_ports.get(port_number) if anchor is not None: - # Process-wide and argument-less: only the port owning the - # slot may stop it (a second port's stop would kill this - # capture while its own flag still says capturing). - if self._kernel_capture_owned_by(anchor): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + # Per-interface stop: only this port's capture is stopped. + await self._ubridge_send(f'capture stop_kernel {anchor}') else: ubridge_bridge = self._ubridge_bridge_name(port_number) await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}") diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 3dcd7174d..341f773ea 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -2012,16 +2012,13 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode): if isinstance(nio, NIOBridge): # Kernel datapath: no relay bridge exists — capture via uBridge's # AF_PACKET module bound to the anchor (a veth host end for the - # standard container, a persistent TAP for an IOL runner). One - # capture per uBridge process: the slot is claimed per anchor, - # and a failed start rolls the port's capturing flag back so the - # port cannot later stop the winner's capture. + # standard container, a persistent TAP for an IOL runner). + # uBridge serves one capture per interface, any number of + # interfaces; a failed start rolls the port's capturing flag back. host_ifc = self._kernel_host_ifc(adapter_number, port_number) try: - self._reserve_kernel_capture(host_ifc) await self._ubridge_send(f'capture start_kernel {host_ifc} "{output_file}"') except Exception: - self._release_kernel_capture(host_ifc) nio.stop_packet_capture() raise return @@ -2043,13 +2040,11 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode): raise DockerError("Cannot stop the packet capture: uBridge is not running") nio = self._ethernet_adapters[adapter_number].get_nio(port_number) if isinstance(nio, NIOBridge): - # Idempotent on the uBridge side (no active capture is OK), but - # process-wide and argument-less: only the port owning the slot - # may stop it, or a second port's stop would kill this capture. + # Per-interface stop: only this port's capture is stopped (the + # capturing flag above gates it, so uBridge's 208-on-idle- + # interface can only surface on a genuine desync). host_ifc = self._kernel_host_ifc(adapter_number, port_number) - if self._kernel_capture_owned_by(host_ifc): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(host_ifc) + await self._ubridge_send(f'capture stop_kernel {host_ifc}') return bridge_name = self._bridge_name(adapter_number, port_number) await self._ubridge_send(f"bridge stop_capture {bridge_name}") diff --git a/gns3server/compute/dynamips/nodes/router.py b/gns3server/compute/dynamips/nodes/router.py index 439d9c10f..863d3eb4d 100644 --- a/gns3server/compute/dynamips/nodes/router.py +++ b/gns3server/compute/dynamips/nodes/router.py @@ -1491,15 +1491,11 @@ class Router(KernelDatapathMixin, BaseNode): if self.ubridge: anchor = self._kernel_host_ifc(slot_number, port_number) if anchor is not None: - # One capture per uBridge process — see - # _reserve_kernel_capture; a failed start rolls the - # port's flag back so it cannot later stop the winner's - # capture. + # One capture per interface; a failed start rolls the + # port's flag back. try: - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise log.debug( @@ -1538,11 +1534,9 @@ class Router(KernelDatapathMixin, BaseNode): if isinstance(nio, NIOBridge): nio.stop_packet_capture() anchor = self._kernel_host_ifc(slot_number, port_number) - if self.ubridge and anchor is not None and self._kernel_capture_owned_by(anchor): - # Process-wide and argument-less: only the port owning the - # slot may stop it. - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + if self.ubridge and anchor is not None: + # Per-interface stop: only this port's capture is stopped. + await self._ubridge_send(f'capture stop_kernel {anchor}') return await nio.stop_packet_capture() diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index f11cac8af..3ec1602a7 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1828,19 +1828,16 @@ class IOUVM(KernelDatapathMixin, BaseNode): if self.ubridge: if isinstance(nio, NIOBridge): # Kernel link: capture on the port's TAP anchor (AF_PACKET, - # one capture per uBridge process — see _reserve_kernel_capture; - # a failed start rolls the port's flag back so the port cannot - # later stop the winner's capture). + # one capture per interface — a failed start rolls the port's + # flag back). anchor = self._kernel_host_ifc(adapter_number, port_number) try: if anchor is None: raise self._kernel_error( f"Bay {adapter_number}/{port_number} of IOU '{self._name}' has no TAP anchor to capture on" ) - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise else: @@ -1869,13 +1866,10 @@ class IOUVM(KernelDatapathMixin, BaseNode): log.debug(f'IOU "{self._name}" [{self._id}]: stopping packet capture on {adapter_number}/{port_number}') if self.ubridge: if isinstance(nio, NIOBridge): - # Process-wide and argument-less: only the port owning the - # slot may stop it (a second port's stop would kill this - # capture while its own flag still says capturing). + # Per-interface stop: only this port's capture is stopped. anchor = self._kernel_host_ifc(adapter_number, port_number) - if self._kernel_capture_owned_by(anchor): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + if anchor is not None: + await self._ubridge_send(f'capture stop_kernel {anchor}') else: await self._ubridge_send( f"iol_bridge stop_capture {self._iol_bridge_name()} {adapter_number} {port_number}" diff --git a/gns3server/compute/kernel_datapath.py b/gns3server/compute/kernel_datapath.py index 4c1836137..f49275a45 100644 --- a/gns3server/compute/kernel_datapath.py +++ b/gns3server/compute/kernel_datapath.py @@ -264,52 +264,6 @@ class KernelDatapathMixin: return not (isinstance(nio, NIOBridge) and nio.bridge is None) - # The anchor whose kernel capture currently holds the uBridge process's - # single AF_PACKET capture slot (None = free; see _reserve_kernel_capture). - # Class-level default so every node type gets it without touching its - # __init__; BaseNode._stop_ubridge resets it when uBridge goes away. - _kernel_capture_ifc = None - - def _reserve_kernel_capture(self, anchor): - """ - Claim uBridge's single kernel-capture slot for *anchor*. - - uBridge serves one AF_PACKET capture per process (a static slot: a - second ``capture start_kernel`` answers EALREADY) while the server - models captures per port — a second kernel port of the same node is - refused here with a clear error. Without the claim the loser's port - would stay marked as capturing, and its stop would issue the - process-wide, argument-less ``capture stop_kernel``, killing the - winner's capture while its own flag still says capturing. - """ - - if self._kernel_capture_ifc is not None and self._kernel_capture_ifc != anchor: - raise self._kernel_error( - f"Cannot start the packet capture: this node's uBridge already captures " - f"{self._kernel_capture_ifc} (one kernel capture per node); stop that capture first" - ) - self._kernel_capture_ifc = anchor - - def _release_kernel_capture(self, anchor): - """ - Release the slot if *anchor* holds it: after a stop, or after a - failed start whose port must not stay marked as capturing. - """ - - if self._kernel_capture_ifc == anchor: - self._kernel_capture_ifc = None - - def _kernel_capture_owned_by(self, anchor): - """ - Whether *anchor* may issue the process-wide ``capture stop_kernel``: - only the port holding the slot — or a node whose slot is empty (a - capture predating the tracker), where stopping is the historical - best-effort behaviour. A second port must never reach it: the - command takes no interface and would stop the owner's capture. - """ - - return self._kernel_capture_ifc is None or self._kernel_capture_ifc == anchor - async def _kernel_attach(self, anchor, nio): """ Attach an anchor to the per-link kernel bridge carried by *nio* @@ -342,10 +296,7 @@ class KernelDatapathMixin: await self._ubridge_send(f'brctl addif "{nio.bridge}" "{anchor}"') if nio.capturing: # Restore a capture that was active before a node restart - # (mirrors the relay path's start_capture). The slot is claimed - # like a fresh start: a second kernel port's replay is refused - # with a clear error instead of an EALREADY deep in the wiring. - self._reserve_kernel_capture(anchor) + # (mirrors the relay path's start_capture). await self._ubridge_send(f'capture start_kernel {anchor} "{nio.pcap_output_file}"') # Markers carried by the NIO attach to the anchor (AF_PACKET taps) — # the anchor is the interface, not a relay bridge. diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index dac2c219f..24e296fe5 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -1758,14 +1758,11 @@ class QemuVM(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(adapter_number) if isinstance(nio, NIOBridge) and anchor is not None: # Kernel link: capture on the adapter's TAP anchor (AF_PACKET, - # one capture per uBridge process — see _reserve_kernel_capture; - # a failed start rolls the port's flag back so the port cannot - # later stop the winner's capture). + # one capture per interface — a failed start rolls the port's + # flag back). try: - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise else: @@ -1792,12 +1789,8 @@ class QemuVM(KernelDatapathMixin, BaseNode): if self.ubridge: anchor = self._kernel_host_ifc(adapter_number) if isinstance(nio, NIOBridge) and anchor is not None: - # Process-wide and argument-less: only the port owning the - # slot may stop it (a second port's stop would kill this - # capture while its own flag still says capturing). - if self._kernel_capture_owned_by(anchor): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + # Per-interface stop: only this port's capture is stopped. + await self._ubridge_send(f'capture stop_kernel {anchor}') else: await self._ubridge_send("bridge stop_capture {name}".format(name=f"QEMU-{self._id}-{adapter_number}")) diff --git a/gns3server/compute/vpcs/vpcs_vm.py b/gns3server/compute/vpcs/vpcs_vm.py index 79b9b772d..823b2a35c 100644 --- a/gns3server/compute/vpcs/vpcs_vm.py +++ b/gns3server/compute/vpcs/vpcs_vm.py @@ -659,14 +659,11 @@ class VPCSVM(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(0, port_number) if isinstance(nio, NIOBridge) and anchor is not None: # Kernel link: capture on the adapter's TAP anchor (AF_PACKET, - # one capture per uBridge process — see _reserve_kernel_capture; - # a failed start rolls the port's flag back so the port cannot - # later stop the winner's capture). + # one capture per interface — a failed start rolls the port's + # flag back). try: - self._reserve_kernel_capture(anchor) await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') except Exception: - self._release_kernel_capture(anchor) nio.stop_packet_capture() raise else: @@ -693,12 +690,8 @@ class VPCSVM(KernelDatapathMixin, BaseNode): if self.ubridge: anchor = self._kernel_host_ifc(0, port_number) if isinstance(nio, NIOBridge) and anchor is not None: - # Process-wide and argument-less: only the port owning the - # slot may stop it (a second port's stop would kill this - # capture while its own flag still says capturing). - if self._kernel_capture_owned_by(anchor): - await self._ubridge_send("capture stop_kernel") - self._release_kernel_capture(anchor) + # Per-interface stop: only this port's capture is stopped. + await self._ubridge_send(f'capture stop_kernel {anchor}') else: await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}")) diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index 3069c45de..9d2d1c76d 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -792,7 +792,7 @@ class TestEthernetSwitchNodesRoutes: ) ) assert response.status_code == status.HTTP_204_NO_CONTENT - node._ubridge_send.assert_any_call("capture stop_kernel") + node._ubridge_send.assert_any_call(f'capture stop_kernel {anchor}') async def test_ethernet_switch_batch_create_anchor_nio( self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index def336b51..107ad69e3 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -997,14 +997,16 @@ async def test_stop_capture_kernel(vm): await vm.stop_capture(0) assert nio.capturing is False - vm._ubridge_send.assert_any_call("capture stop_kernel") + vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc}') @pytest.mark.asyncio -async def test_start_capture_second_concurrent_fails(vm): +async def test_start_capture_ubridge_ealready_rolls_the_flag_back(vm): """ - uBridge's kernel capture is a singleton per process: a second concurrent - capture returns EALREADY and must surface to the caller. + uBridge answers EALREADY when its interface is already being captured + (the server's per-port flag should have refused earlier; this is the + desync net): the error must surface and the port must not stay marked + as capturing. """ async def send(command): @@ -1022,19 +1024,16 @@ async def test_start_capture_second_concurrent_fails(vm): await vm.start_capture(0, "/tmp/capture.pcap") # the failed start must not leave the port marked as capturing: a stuck - # flag refuses the retry and lets this port's stop kill the winner + # flag refuses the retry assert nio.capturing is False - assert vm._kernel_capture_ifc is None @pytest.mark.asyncio -async def test_start_capture_second_port_on_the_same_node_is_refused(vm): +async def test_capture_two_ports_at_once_and_stop_them_independently(vm): """ - uBridge serves one kernel capture per process while the server models - captures per port: a second port's capture must be refused with a clear - error and its flag rolled back — otherwise its stop would issue the - process-wide, argument-less ``capture stop_kernel`` and kill the first - port's capture while its own flag still says capturing. + uBridge serves one kernel capture per interface: two ports of the same + node capture concurrently, and each port's stop names only its own + anchor — a sibling port's capture is never touched. """ vm._ubridge_hypervisor = MagicMock() @@ -1051,48 +1050,22 @@ async def test_start_capture_second_port_on_the_same_node_is_refused(vm): vm._ethernet_adapters[1].add_nio(0, nio2) await vm.start_capture(0, "/tmp/capture.pcap") - - with pytest.raises(DockerError, match="one kernel capture per node"): - await vm.start_capture(1, "/tmp/capture2.pcap") + await vm.start_capture(1, "/tmp/capture2.pcap") assert nio1.capturing is True - assert nio2.capturing is False + assert nio2.capturing is True vm._ubridge_send.assert_any_call(f'capture start_kernel {host_ifc1} "/tmp/capture.pcap"') - assert not any("stop_kernel" in str(c) for c in vm._ubridge_send.call_args_list) - - -@pytest.mark.asyncio -async def test_stop_capture_on_a_port_that_does_not_own_the_slot_skips_stop_kernel(vm): - """ - ``capture stop_kernel`` is process-wide and takes no interface: a stop on - a port whose flag says capturing while another port owns the slot must - not reach uBridge — it would stop the owner's capture. - """ - - vm._ubridge_hypervisor = MagicMock() - vm._ubridge_send = AsyncioMagicMock() - vm.status = "started" - vm._ethernet_adapters.append(type(vm._ethernet_adapters[0])()) - host_ifc1, _ = vm._veth_names(0, 0) - host_ifc2, _ = vm._veth_names(1, 0) - vm._kernel_veths[(0, 0)] = host_ifc1 - vm._kernel_veths[(1, 0)] = host_ifc2 - nio1 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) - nio2 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) - vm._ethernet_adapters[0].add_nio(0, nio1) - vm._ethernet_adapters[1].add_nio(0, nio2) - - await vm.start_capture(0, "/tmp/capture.pcap") # port 0 owns the slot - nio2.start_packet_capture("/tmp/capture2.pcap") # a stale second flag + vm._ubridge_send.assert_any_call(f'capture start_kernel {host_ifc2} "/tmp/capture2.pcap"') await vm.stop_capture(1) + assert nio1.capturing is True assert nio2.capturing is False - assert not any("stop_kernel" in str(c) for c in vm._ubridge_send.call_args_list) + vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc2}') + assert not any(f'stop_kernel {host_ifc1}' in str(c) for c in vm._ubridge_send.call_args_list) - # the owner's capture is untouched and still stoppable await vm.stop_capture(0) - vm._ubridge_send.assert_any_call("capture stop_kernel") + vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc1}') @pytest.mark.asyncio diff --git a/tests/compute/dynamips/test_dynamips_kernel_datapath.py b/tests/compute/dynamips/test_dynamips_kernel_datapath.py index d1af1c808..230a6ca79 100644 --- a/tests/compute/dynamips/test_dynamips_kernel_datapath.py +++ b/tests/compute/dynamips/test_dynamips_kernel_datapath.py @@ -581,7 +581,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(router): assert not any("bind_filter" in c for c in _hypervisor_commands(router)) await router.stop_capture(0, 0) - router._ubridge_send.assert_any_call("capture stop_kernel") + router._ubridge_send.assert_any_call(f'capture stop_kernel {TAP00}') @pytest.mark.asyncio diff --git a/tests/compute/iou/test_iou_kernel_datapath.py b/tests/compute/iou/test_iou_kernel_datapath.py index 427f19502..0e6df1368 100644 --- a/tests/compute/iou/test_iou_kernel_datapath.py +++ b/tests/compute/iou/test_iou_kernel_datapath.py @@ -371,7 +371,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm): vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP00} "/tmp/test.pcap"') await vm.stop_capture(0, 0) - vm._ubridge_send.assert_any_call("capture stop_kernel") + vm._ubridge_send.assert_any_call(f'capture stop_kernel {TAP00}') @pytest.mark.asyncio diff --git a/tests/compute/qemu/test_qemu_kernel_datapath.py b/tests/compute/qemu/test_qemu_kernel_datapath.py index 9c5c211c0..79709d3c9 100644 --- a/tests/compute/qemu/test_qemu_kernel_datapath.py +++ b/tests/compute/qemu/test_qemu_kernel_datapath.py @@ -328,7 +328,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm): vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP0} "/tmp/test.pcap"') await vm.stop_capture(0) - vm._ubridge_send.assert_any_call("capture stop_kernel") + vm._ubridge_send.assert_any_call(f'capture stop_kernel {TAP0}') @pytest.mark.asyncio diff --git a/tests/e2e/test_docker_kernel_datapath.py b/tests/e2e/test_docker_kernel_datapath.py index f3473ef55..d1ed9528d 100644 --- a/tests/e2e/test_docker_kernel_datapath.py +++ b/tests/e2e/test_docker_kernel_datapath.py @@ -316,6 +316,108 @@ def test_docker_kernel_fast_path(): assert harness.wait_until(lambda: harness.bridge_members(bridge) is None, timeout=10), bridge +def test_docker_kernel_ten_concurrent_captures(): + """Ten kernel captures at once on one node's uBridge. The capture side + picks the first local started node, so all ten links capture on node 1's + single uBridge process — the scale the old one-capture-per-process limit + could never reach. Each link gets its own traffic (its own subnet on its + own eth), and a partial stop must leave the untouched captures running.""" + server = harness.live_server(kernel=True) + compute = server.compute + image = harness.ensure_docker_image(server) + + project = compute.create_project("docker-multicap-e2e") + pid = project["project_id"] + try: + daemon = harness.DockerDaemon() + nodes = [] + for suffix, address in (("1", R1_IP), ("2", R2_IP)): + node = compute.create_docker_node(pid, f"MC-{suffix}", image, adapters=10) + harness.configure_docker_interfaces(node, address) + nodes.append(_resolve_container(daemon, node)) + n1, n2 = nodes + c1, c2 = n1["container_id"], n2["container_id"] + n1_id, n2_id = n1["node_id"], n2["node_id"] + + links = [compute.create_link(pid, (n1_id, i, 0), (n2_id, i, 0)) for i in range(10)] + assert all(link["kernel_datapath"] is True for link in links), links + + print(".. starting containers") + for node in nodes: + compute.call("POST", f"/projects/{pid}/nodes/{node['node_id']}/start") + harness.docker_wait_address(daemon, c1, R1_IP) + harness.docker_wait_address(daemon, c2, R2_IP) + + # ten captures before any of the per-link interfaces is addressed, so + # every later frame (up to and including the ARP each first ping + # triggers) lands in a pcap + captures = [ + compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}) + for link in links + ] + + # eth1..eth9: one subnet each, wired by hand inside the guests; the + # settle window lets the guests' address-add chatter (each v4 addr + # fires two IGMP reports ~1 s apart) land inside an open capture + # instead of a later snapshot + for i in range(1, 10): + for container_id, host in ((c1, 1), (c2, 2)): + code, output = daemon.exec( + container_id, + ["sh", "-c", f"ip link set eth{i} up && ip addr add 10.20.{i}.{host}/24 dev eth{i}"], + ) + assert code == 0, output + time.sleep(2) + + def ping_link(i): + # link 0 rides the init.sh-addressed eth0; the rest their own subnet + if i == 0: + result = harness.docker_ping(daemon, c1, R2_IP, count=2) + assert result["loss"] == 0, result["raw"] + else: + code, output = daemon.exec(c1, ["ping", "-c", "2", "-W", "1", f"10.20.{i}.2"]) + assert code == 0, output + + for i in range(10): + ping_link(i) + time.sleep(0.5) + + paths = [capture["capture_file_path"] for capture in captures] + first_counts = [harness.pcap_records(path)[0] for path in paths] + # 2 ARP + 2 echo requests + 2 replies is the floor per link + assert all(count >= 4 for count in first_counts), list(zip(paths, first_counts)) + print(f".. all 10 pcaps captured concurrently: {first_counts}") + + # stop half. A stop is synchronous (the reply follows the reader + # thread's join), so from its return that pcap can no longer grow — + # the post-stop counts are an exact-freeze baseline + for link in links[5:]: + compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/stop") + stopped_base = [harness.pcap_records(paths[i])[0] for i in range(5, 10)] + + # fresh traffic on ALL segments: the stopped half must stay frozen + # even while its links carry frames, the live half must record them + for i in range(10): + ping_link(i) + time.sleep(0.5) + + for i in range(5, 10): + assert harness.pcap_records(paths[i])[0] == stopped_base[i - 5], f"stopped pcap {i} grew" + for i in range(5): + assert harness.pcap_records(paths[i])[0] >= first_counts[i] + 4, f"live pcap {i} did not grow" + print(".. per-interface stop froze exactly the stopped half") + + for link in links[:5]: + compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/stop") + assert all( + compute.call("GET", f"/projects/{pid}/links/{link['link_id']}")["capturing"] is False for link in links + ) + except BaseException: + harness.release(server, pid, failed=True) + raise + harness.release(server, pid, failed=False) + + def test_docker_kernel_filter_matrix(): """One filter type at a time on a live kernel link, each asserted by its effect on real traffic and its tc state. Types the uBridge on this From 97b55141349bee21583affff36800d0b290f6b88 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 14 Oct 2026 22:09:53 +0800 Subject: [PATCH 2/3] style: zip() with strict= in the capture e2e assert message --- tests/e2e/test_docker_kernel_datapath.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test_docker_kernel_datapath.py b/tests/e2e/test_docker_kernel_datapath.py index d1ed9528d..ed75fff5c 100644 --- a/tests/e2e/test_docker_kernel_datapath.py +++ b/tests/e2e/test_docker_kernel_datapath.py @@ -385,7 +385,7 @@ def test_docker_kernel_ten_concurrent_captures(): paths = [capture["capture_file_path"] for capture in captures] first_counts = [harness.pcap_records(path)[0] for path in paths] # 2 ARP + 2 echo requests + 2 replies is the floor per link - assert all(count >= 4 for count in first_counts), list(zip(paths, first_counts)) + assert all(count >= 4 for count in first_counts), list(zip(paths, first_counts, strict=True)) print(f".. all 10 pcaps captured concurrently: {first_counts}") # stop half. A stop is synchronous (the reply follows the reader From 3e6a53c4b9bc8cd624dc4c4626a5fcd96c70b713 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 14 Oct 2026 22:13:58 +0800 Subject: [PATCH 3/3] style: apply ruff format to the files this branch touches --- gns3server/compute/builtin/nodes/ethernet_switch.py | 2 +- gns3server/compute/docker/docker_vm.py | 2 +- gns3server/compute/dynamips/nodes/router.py | 2 +- gns3server/compute/iou/iou_vm.py | 2 +- gns3server/compute/qemu/qemu_vm.py | 2 +- gns3server/compute/vpcs/vpcs_vm.py | 2 +- tests/api/routes/compute/test_ethernet_switch_nodes.py | 2 +- tests/compute/docker/test_docker_kernel_datapath.py | 8 ++++---- tests/compute/dynamips/test_dynamips_kernel_datapath.py | 2 +- tests/compute/iou/test_iou_kernel_datapath.py | 2 +- tests/compute/qemu/test_qemu_kernel_datapath.py | 2 +- tests/e2e/test_docker_kernel_datapath.py | 4 +++- 12 files changed, 17 insertions(+), 15 deletions(-) diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index 5989ccc7c..9d8b55b55 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -795,7 +795,7 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): anchor = self._kernel_ports.get(port_number) if anchor is not None: # Per-interface stop: only this port's capture is stopped. - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") else: ubridge_bridge = self._ubridge_bridge_name(port_number) await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}") diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 341f773ea..9e99c9104 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -2044,7 +2044,7 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode): # capturing flag above gates it, so uBridge's 208-on-idle- # interface can only surface on a genuine desync). host_ifc = self._kernel_host_ifc(adapter_number, port_number) - await self._ubridge_send(f'capture stop_kernel {host_ifc}') + await self._ubridge_send(f"capture stop_kernel {host_ifc}") return bridge_name = self._bridge_name(adapter_number, port_number) await self._ubridge_send(f"bridge stop_capture {bridge_name}") diff --git a/gns3server/compute/dynamips/nodes/router.py b/gns3server/compute/dynamips/nodes/router.py index 863d3eb4d..59a414851 100644 --- a/gns3server/compute/dynamips/nodes/router.py +++ b/gns3server/compute/dynamips/nodes/router.py @@ -1536,7 +1536,7 @@ class Router(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(slot_number, port_number) if self.ubridge and anchor is not None: # Per-interface stop: only this port's capture is stopped. - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") return await nio.stop_packet_capture() diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 3ec1602a7..679050f6f 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1869,7 +1869,7 @@ class IOUVM(KernelDatapathMixin, BaseNode): # Per-interface stop: only this port's capture is stopped. anchor = self._kernel_host_ifc(adapter_number, port_number) if anchor is not None: - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") else: await self._ubridge_send( f"iol_bridge stop_capture {self._iol_bridge_name()} {adapter_number} {port_number}" diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index 24e296fe5..eb2f2eb79 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -1790,7 +1790,7 @@ class QemuVM(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(adapter_number) if isinstance(nio, NIOBridge) and anchor is not None: # Per-interface stop: only this port's capture is stopped. - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") else: await self._ubridge_send("bridge stop_capture {name}".format(name=f"QEMU-{self._id}-{adapter_number}")) diff --git a/gns3server/compute/vpcs/vpcs_vm.py b/gns3server/compute/vpcs/vpcs_vm.py index 823b2a35c..df9c31ab1 100644 --- a/gns3server/compute/vpcs/vpcs_vm.py +++ b/gns3server/compute/vpcs/vpcs_vm.py @@ -691,7 +691,7 @@ class VPCSVM(KernelDatapathMixin, BaseNode): anchor = self._kernel_host_ifc(0, port_number) if isinstance(nio, NIOBridge) and anchor is not None: # Per-interface stop: only this port's capture is stopped. - await self._ubridge_send(f'capture stop_kernel {anchor}') + await self._ubridge_send(f"capture stop_kernel {anchor}") else: await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}")) diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index 9d2d1c76d..2c03b698c 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -792,7 +792,7 @@ class TestEthernetSwitchNodesRoutes: ) ) assert response.status_code == status.HTTP_204_NO_CONTENT - node._ubridge_send.assert_any_call(f'capture stop_kernel {anchor}') + node._ubridge_send.assert_any_call(f"capture stop_kernel {anchor}") async def test_ethernet_switch_batch_create_anchor_nio( self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index 107ad69e3..abfc23220 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -997,7 +997,7 @@ async def test_stop_capture_kernel(vm): await vm.stop_capture(0) assert nio.capturing is False - vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc}') + vm._ubridge_send.assert_any_call(f"capture stop_kernel {host_ifc}") @pytest.mark.asyncio @@ -1061,11 +1061,11 @@ async def test_capture_two_ports_at_once_and_stop_them_independently(vm): assert nio1.capturing is True assert nio2.capturing is False - vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc2}') - assert not any(f'stop_kernel {host_ifc1}' in str(c) for c in vm._ubridge_send.call_args_list) + vm._ubridge_send.assert_any_call(f"capture stop_kernel {host_ifc2}") + assert not any(f"stop_kernel {host_ifc1}" in str(c) for c in vm._ubridge_send.call_args_list) await vm.stop_capture(0) - vm._ubridge_send.assert_any_call(f'capture stop_kernel {host_ifc1}') + vm._ubridge_send.assert_any_call(f"capture stop_kernel {host_ifc1}") @pytest.mark.asyncio diff --git a/tests/compute/dynamips/test_dynamips_kernel_datapath.py b/tests/compute/dynamips/test_dynamips_kernel_datapath.py index 230a6ca79..135a69f6e 100644 --- a/tests/compute/dynamips/test_dynamips_kernel_datapath.py +++ b/tests/compute/dynamips/test_dynamips_kernel_datapath.py @@ -581,7 +581,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(router): assert not any("bind_filter" in c for c in _hypervisor_commands(router)) await router.stop_capture(0, 0) - router._ubridge_send.assert_any_call(f'capture stop_kernel {TAP00}') + router._ubridge_send.assert_any_call(f"capture stop_kernel {TAP00}") @pytest.mark.asyncio diff --git a/tests/compute/iou/test_iou_kernel_datapath.py b/tests/compute/iou/test_iou_kernel_datapath.py index 0e6df1368..7121800e3 100644 --- a/tests/compute/iou/test_iou_kernel_datapath.py +++ b/tests/compute/iou/test_iou_kernel_datapath.py @@ -371,7 +371,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm): vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP00} "/tmp/test.pcap"') await vm.stop_capture(0, 0) - vm._ubridge_send.assert_any_call(f'capture stop_kernel {TAP00}') + vm._ubridge_send.assert_any_call(f"capture stop_kernel {TAP00}") @pytest.mark.asyncio diff --git a/tests/compute/qemu/test_qemu_kernel_datapath.py b/tests/compute/qemu/test_qemu_kernel_datapath.py index 79709d3c9..a7226c08b 100644 --- a/tests/compute/qemu/test_qemu_kernel_datapath.py +++ b/tests/compute/qemu/test_qemu_kernel_datapath.py @@ -328,7 +328,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm): vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP0} "/tmp/test.pcap"') await vm.stop_capture(0) - vm._ubridge_send.assert_any_call(f'capture stop_kernel {TAP0}') + vm._ubridge_send.assert_any_call(f"capture stop_kernel {TAP0}") @pytest.mark.asyncio diff --git a/tests/e2e/test_docker_kernel_datapath.py b/tests/e2e/test_docker_kernel_datapath.py index ed75fff5c..877ec02cf 100644 --- a/tests/e2e/test_docker_kernel_datapath.py +++ b/tests/e2e/test_docker_kernel_datapath.py @@ -352,7 +352,9 @@ def test_docker_kernel_ten_concurrent_captures(): # every later frame (up to and including the ARP each first ping # triggers) lands in a pcap captures = [ - compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}) + compute.call( + "POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} + ) for link in links ]