From 6c73bb15f052b8b65ef12f7f882aaacb304afb68 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 5 Oct 2026 22:19:17 +0800 Subject: [PATCH] docs(bugs): embed the runnable TAP-release reproducer and the measurement method The reproduction section described the harness only in prose; the actual driver scripts were throwaway lab files. Embed the distilled, runnable reproducer (drives uBridge's control socket through the repo's own client, kept tap for the single-variable A/B), the exact measurement semantics (40 re-enslavements, ~1 kHz / 200 ms membership sampling, a release = the brif entry vanishing, latency print) and the flip protocol, so a reader can reproduce the 19-20/40 vs 0/40 numbers on an affected host. Also point the host-fix layer at the reproducer for re-verification. --- docs/bugs/networkmanager-tap-release.md | 108 +++++++++++++++++++++++- 1 file changed, 104 insertions(+), 4 deletions(-) diff --git a/docs/bugs/networkmanager-tap-release.md b/docs/bugs/networkmanager-tap-release.md index cf845d72c..473e1160f 100644 --- a/docs/bugs/networkmanager-tap-release.md +++ b/docs/bugs/networkmanager-tap-release.md @@ -42,13 +42,113 @@ Docker veth host ends are not NM-managed (and the veth naming/type heuristics hi ## Reproduction (minimal, gns3-server not involved) -Self-contained harness: one uBridge (`ubridge -U `), one fresh TAP (`tap create`), one kernel bridge (`brctl create` + `vlanfiltering on`), then loop { `link set up`, `brctl addif
`, 200 ms of ~1 kHz membership sampling, `brctl delif`, `link set down` }. Measured on an affected host (same code, five baseline runs, then single-variable A/B on one tap): +A self-contained reproducer — one uBridge, one TAP, one kernel bridge, an enslavement loop — needs none of the server. It drives uBridge's control socket through the repository's own client, so no build step and no extra dependencies beyond the venv: + +```python +#!/usr/bin/env python3 +"""Minimal reproducer: NetworkManager releases an enslaved TAP from its bridge. + +Run from the gns3-server repo root on an affected host (Linux + NetworkManager +managing the TAPs; uBridge installed with cap_net_admin): + + venv/bin/python nm_tap_release_repro.py # 40 re-enslavements + nmcli device set gdlab-nm managed no # the A/B flip (same tap!) + venv/bin/python nm_tap_release_repro.py # -> 0 releases + +The tap and bridge are kept between runs (same device = same NM state). +Clean up afterwards: + + venv/bin/python nm_tap_release_repro.py --cleanup +""" +import asyncio +import os +import shutil +import subprocess +import sys +import time + +sys.path.insert(0, os.getcwd()) # run from the repo root for gns3server imports +from gns3server.compute.ubridge.ubridge_hypervisor import UBridgeHypervisor + +TAP = "gdlab-nm" +BRIDGE = "gns3lab-nm" +SOCK = "/tmp/nm_tap_release_repro.sock" +ITERATIONS = 40 +SAMPLE_MS = 200 # membership sampled at ~1 kHz for this long after each addif + + +def in_bridge(): + return os.path.exists(f"/sys/class/net/{BRIDGE}/brif/{TAP}") + + +async def main(): + ubridge = shutil.which("ubridge") + if not ubridge: + sys.exit("ubridge not found in PATH") + if os.path.exists(SOCK): + os.unlink(SOCK) + proc = subprocess.Popen( + [ubridge, "-U", SOCK], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd="/tmp" + ) + try: + h = UBridgeHypervisor(socket_path=SOCK) + await h.connect() + + async def send(cmd): + try: + await h.send(cmd) + except Exception: + pass # ENOENT during setup/teardown is expected + + if "--cleanup" in sys.argv: + await send(f'tap delete "{TAP}"') + await send(f'brctl delete "{BRIDGE}"') + return + await send(f'tap create "{TAP}"') # no-op if the tap already exists + await send(f'brctl create "{BRIDGE}"') + await send(f'link set "{BRIDGE}" up') + await send(f'brctl vlanfiltering "{BRIDGE}" on') + + releases, latencies = 0, [] + for _ in range(ITERATIONS): + await send(f'link set "{TAP}" up') + await send(f'brctl addif "{BRIDGE}" "{TAP}"') + t0 = time.time() + for _ in range(SAMPLE_MS): + await asyncio.sleep(0.001) + if not in_bridge(): + releases += 1 + latencies.append(round((time.time() - t0) * 1000, 1)) + break + await send(f'brctl delif "{BRIDGE}" "{TAP}"') + await send(f'link set "{TAP}" down') + await asyncio.sleep(0.005) + + print(f"releases: {releases}/{ITERATIONS} re-enslavements", flush=True) + if latencies: + print(f"release latency after join (ms): {latencies[:10]}") + print(f"expect ~19-20/40 while NetworkManager manages {TAP}, " + f"0/40 after `nmcli device set {TAP} managed no`") + finally: + proc.kill() + + +asyncio.run(main()) +``` + +How the numbers are produced: + +- 40 iterations; each is one **re-enslavement**: `link set up`, `brctl addif`, then membership sampled at ~1 kHz for 200 ms, then `brctl delif` + `link set down`. +- A **release** is counted when `/sys/class/net//brif/` disappears inside that 200 ms window; the script prints the release latency after the join (the NM release lands at 58-87 ms). +- The tap is kept between runs, so the A/B flip is single-variable on the *same* device; a fresh device would reset NM's assumption state (the first enslavement of a fresh tap is always stable). + +Measured on an affected host (same code, five baseline runs, then the A/B between runs): | Condition | Releases / 40 re-enslavements | |---|---| | NM-managed (default) | 19, 20, 19, 20, 20 | -| `nmcli device set managed no` | **0** | -| re-managed (`nmcli device set managed yes`) | 20 | +| `nmcli device set managed no`, same tap | **0** | +| re-managed (`nmcli device set managed yes`), same tap | 20 | e2e-level reproduction: `tests/e2e/test_ethernet_switch_kernel_datapath.py::test_ethernet_switch_kernel_fast_path` fails at its link delete/recreate step (`wait_ping` after the anchor re-join) roughly half of fresh runs on an NM host — it failed twice consecutively on 2026-10-05 during the 3.1 rebase validation before the fix. @@ -63,7 +163,7 @@ Three layers, of which the first is the verified root fix: unmanaged-devices=interface-name:gd*;interface-name:gq*;interface-name:gi*;interface-name:gx*;interface-name:gv*;interface-name:gns3* ``` - then `systemctl reload NetworkManager`. Devices matching are unmanaged from birth (also future ones and after re-creations); hosts without NM treat the file as inert. Verified on the affected host: minimal loop **0/40**, and the e2e scenario passes with the test-side workaround disabled (pure host config). This belongs in installers/documentation, not in the server (no root; and the server must not reconfigure the host's NM). + then `systemctl reload NetworkManager`. Devices matching are unmanaged from birth (also future ones and after re-creations); hosts without NM treat the file as inert. Verified on the affected host: minimal loop **0/40**, and the e2e scenario passes with the test-side workaround disabled (pure host config); re-verify with the reproducer below — same tap, expect 0/40 after the reload. This belongs in installers/documentation, not in the server (no root; and the server must not reconfigure the host's NM). 2. **Test-side best effort** — `harness.unmanage_from_networkmanager(*taps)` runs `nmcli device set managed no` where nmcli exists (no-op otherwise); kernel-datapath scenarios that re-enslave anchors call it after their nodes have booted. This keeps the e2e deterministic on un-declared hosts.