feat: concurrent kernel captures, one per interface

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.
This commit is contained in:
YueGuobin committed 2026-10-14 21:52:34 +08:00
1 parent 149549e029
commit 9e0d5050db
15 files changed
+155 -171

No files matched your search

-4
View File
@@ -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):
"""
@@ -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}")
+7 -12
View File
@@ -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}")
+5 -11
View File
@@ -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()
+5 -11
View File
@@ -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}"
+1 -50
View File
@@ -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.
+4 -11
View File
@@ -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}"))
+4 -11
View File
@@ -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}"))