From 81ad9b837f55c068c946d1b8ac6fce515e817e3b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 30 Sep 2026 19:41:32 +0800 Subject: [PATCH] docs(spec): record the delivered L2-only anchors and their residual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uBridge delivered `link l2only` (e2e3155) and the brctl delete refusal (83a84fb); verified from the server side -- anchors and per-link bridges report addrgenmode none, carry no addresses, and an idle anchor is silent once the one-shot membership burst has settled. That burst is the one thing the command cannot remove, so it is now recorded where the acceptance criteria live, with the measurement: enslaving a port makes the kernel announce the bridge's multicast memberships (90 B MLDv2 at +0.00 s, 54 B IGMPv3 at +0.09 s, 90 B MLDv2 at +0.73 s, then silence; no addresses involved, and IPv4 has no per-device IGMP switch). Decision: accepted and documented, not suppressed -- §E.2 measures idle silence after a settle window instead. Pins the other delivered behaviour with a test: a bridge now survives a single-sided node stop, so brctl create answers EEXIST on the common path rather than only in a create race -- the attach must not short-circuit there, because the restarted side has a brand-new anchor whose filters and markers still have to be re-applied. --- docs/design/ubridge-l2-anchor-spec.md | 53 +++++++++++++++---- docs/features/qemu-kernel-datapath.md | 12 ++++- .../docker/test_docker_kernel_datapath.py | 31 +++++++++++ 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/design/ubridge-l2-anchor-spec.md b/docs/design/ubridge-l2-anchor-spec.md index 6fef6d106..8c6afad71 100644 --- a/docs/design/ubridge-l2-anchor-spec.md +++ b/docs/design/ubridge-l2-anchor-spec.md @@ -3,11 +3,17 @@ SPDX-License-Identifier: CC-BY-SA-4.0 See LICENSE file for licensing information. --> -> Frozen requirements spec for the **uBridge** project. Status: **not yet -> delivered.** Requested by gns3-server branch `feat/qemu-kernel-datapath` -> (QEMU persistent-TAP anchors) and, retroactively, by the shipped Docker -> kernel-datapath stack: today every host-side anchor device carries an IPv6 -> identity, so emulated links carry the *host's* IPv6 chatter. +> Frozen requirements spec for the **uBridge** project. Status: **delivered** +> (uBridge `e2e3155` — `link l2only` plus the creators that apply it) and +> verified from the server side: anchors and per-link bridges report +> `addrgenmode none`, `ip -4/-6 addr` are empty, and an idle anchor is silent +> once the one-shot membership burst of §0 has settled. +> +> **Residual, accepted (measured after delivery):** enslaving a port still +> makes the *bridge device* announce its multicast memberships — one IGMPv3 +> plus one or two MLDv2 reports in the first second — which floods into the +> emulated segment. It is outside this command's reach by design (no addresses +> are involved, and IPv4 has no per-device IGMP off switch); see §0. > > Scope guard: no wire format, no command grammar change to existing > commands — this adds one command and one internal step in four creators. @@ -53,6 +59,32 @@ invariant we keep is *no addresses at all* (measured: `ip -4 addr` is empty on every anchor and per-link bridge on the deployment above, with NetworkManager reporting them `unmanaged`). IPv6 is the only protocol that lights itself up. +### The residual this command does not remove (measured after delivery) + +With `l2only` in place (anchors and bridges report `addrgenmode none`, no +addresses), **enslaving a port still makes the bridge device speak once** — +the kernel announces the multicast memberships the enslavement brings to the +bridge: + +``` ++0.00 s 90 B IPv6 MLDv2 report dst 33:33:00:00:00:16 (ICMPv6 143) ++0.09 s 54 B IPv4 IGMPv3 report dst 01:00:5e:00:00:16 0.0.0.0 → 224.0.0.22 ++0.73 s 90 B IPv6 MLDv2 report dst 33:33:00:00:00:16 +``` + +(Scratch tap pair + per-link bridge on the test host, unprivileged; source MAC += the bridge's. A 10 s window contains exactly those three frames, then silence. +The same burst appears through the server: one 90 B IPv6 frame inside a 2 s +window right after a link is created.) + +It is outside `l2only`'s reach by design: no addresses are involved (the report +sources are `::` and `0.0.0.0`), and IPv4 has no per-device "no IGMP" knob to +set. **Decision: accepted and documented, not suppressed** — it is standard L2 +control traffic, one-shot per link creation or port change, and it stops. The +acceptance test in §E.2 therefore measures after a settle window. Suppressing +it (e.g. `disable_ipv6` on the bridge before enslaving, for the MLD half) would +be a new, separate requirement and has not been asked for. + ### Goal Every host-side device uBridge creates for the data plane is **pure L2**: no @@ -147,11 +179,12 @@ data plane hardens it, and the acceptance checks in §E apply to it. 1. **No addresses.** After each creator: `ip -6 addr show dev ` is empty (in particular no `fe80::`) and `ip -4 addr show dev ` is empty; the same for the bridge created by `brctl create`. -2. **Idle silence.** With the device UP and in its normal role (TAP with an open - fd / veth with its peer up / bridge with two attached ports), nothing appears - on it for 5 s — measured from the peer end, or via - `capture start_kernel ` while idle and by asserting the pcap has - zero packets. Baseline today: 6 frames / 2 s. +2. **Idle silence, after the settle window.** Let the last port enslavement + settle (≥ 2 s — that burst is expected, see §0), then with the device UP and + in its normal role (TAP with an open fd / veth with its peer up / bridge with + two attached ports) nothing appears on it for 5 s — measured from the peer + end, or via `capture start_kernel ` while idle with the pcap + asserted empty. Baseline before this work: 6 frames / 2 s, continuous. 3. **Idempotency & revert.** `on` twice → `100` both times, state unchanged; `off` restores the kernel default (link-local returns after the device is cycled down/up). diff --git a/docs/features/qemu-kernel-datapath.md b/docs/features/qemu-kernel-datapath.md index 045b43dad..1797ca4c6 100644 --- a/docs/features/qemu-kernel-datapath.md +++ b/docs/features/qemu-kernel-datapath.md @@ -148,8 +148,16 @@ enable_kernel_datapath = True Anchors are host-side netdevs, so the kernel gives them an IPv6 link-local address and emits MLD/DAD from them; those frames flood into the emulated segment. The hardening requirement (anchors must be pure L2, no L3 identity) -is specified for uBridge in `docs/design/ubridge-l2-anchor-spec.md` — it -applies to QEMU TAPs and Docker veths alike. +is specified for uBridge in `docs/design/ubridge-l2-anchor-spec.md` and is +**delivered**: anchors and per-link bridges come up with `addrgenmode none`, +no addresses, and an idle anchor is silent (asserted in the e2e after a 2.5 s +settle). + +One residual is accepted and documented there: enslaving a port makes the +*kernel* announce the bridge's multicast memberships once — an IGMPv3 plus one +or two MLDv2 reports in the first second — which floods into the segment like +any L2 control frame. It stops; it is outside the L2-only command's reach (no +addresses involved, IPv4 has no per-device IGMP switch). ## Roadmap diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index f890b5fce..f68ddb11f 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -291,6 +291,37 @@ async def test_connect_nio_kernel_tolerates_bridge_create_race(vm): vm._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{host_ifc}"') +@pytest.mark.asyncio +async def test_connect_nio_kernel_eexist_still_reapplies_everything(vm): + """ + EEXIST is a *common* path now: a bridge survives a single-sided node + restart (the peer's still-enslaved port makes brctl delete answer EBUSY, + which is suppressed), so the restarted side finds its bridge already + there. The create-or-verify step must not short-circuit the rest of the + attach — the restarted side has a brand-new anchor and needs its capture, + markers and filters re-applied on it. + """ + + async def send(command): + if "brctl create" in command: + raise UbridgeError("Could not create bridge gns3a1b2c3d4e5f: File exists (EEXIST)") + + vm._ubridge_send = AsyncioMagicMock(side_effect=send) + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + nio.filters = {"delay": [10]} + + await vm._connect_nio(0, nio) + + vm._ubridge_send.assert_any_call(f'brctl show "{BRIDGE}"') + vm._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{host_ifc}"') + # ... and the port-level configuration of the fresh anchor goes on top of + # it, unconditionally (never inside a "the bridge was created" branch). + vm._ubridge_send.assert_any_call(f'tc reset "{host_ifc}"') + vm._ubridge_send.assert_any_call(f'tc netem set "{host_ifc}" delay 10') + + @pytest.mark.asyncio async def test_connect_nio_kernel_create_failure_propagates(vm):