7869 Commits

Author SHA1 Message Date
YueGuobin
f688e654e1
test(e2e): widen the fast-path RTT bound to 100 ms via harness.FAST_RTT_MS
The 'impairment cleared' asserts used avg < 50 on three-packet IOS pings;
the unimpaired baseline is 20-45 ms with 4 ms quantization, and host
scheduling jitter pushed averages to 54/56 in two full-suite runs
(esw, dynamips) — a margin, not an impairment (the qdisc asserts pass,
success is 100 %). The contrasted delayed regime is >= 150 ms, so a
100 ms bound still separates the two cleanly. Docker's sub-millisecond
veth asserts keep their 50 ms bound (three orders of magnitude of
headroom, never observed to flake).
2026-10-06 00:21:11 +08:00
YueGuobin
f21d7be99c
test(e2e): derive busybox ping loss from replies when the summary is lost
The exec stream can truncate after busybox's 4294967 ms RTT timer-wrap
artifact, dropping the summary line — docker_ping then reported a
phantom 100 % loss and made the corrupt-filter scenario fail spuriously.
Factor the parsing into _busybox_ping_parse: the summary stays
authoritative when present; otherwise the loss is derived from the
seq= replies actually seen (no replies at all still reports 100 %).
2026-10-06 00:21:11 +08:00
YueGuobin
15fbef93fb
test(e2e): unmanage the TAP anchors in the remaining kernel scenarios
Same guard the ethernet-switch scenario already carries: NetworkManager
can release an NM-managed TAP from its bridge shortly after
(re-)enslavement (docs/bugs/networkmanager-tap-release.md). Applied to
the Dynamips, QEMU, IOU, IOL-runner and switch-cascade scenarios after
their nodes have booted (no-op where nmcli/NetworkManager is absent).
This keeps those tests deterministic on un-declared hosts.
2026-10-06 00:21:11 +08:00
YueGuobin
28e9b82a5f
docs(bugs): managed-yes cannot re-arm the baseline once the conf matches 2026-10-06 00:21:11 +08:00
YueGuobin
20b7968e33
docs(bugs): note how to re-observe the baseline on an already-declared host 2026-10-06 00:21:11 +08:00
YueGuobin
6c73bb15f0
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.
2026-10-06 00:21:11 +08:00
YueGuobin
3018d3aecc
docs(bugs): record the NetworkManager TAP-release root cause, repro and fix
A kernel-datapath link could die silently after a delete/recreate: on
NetworkManager hosts, NM's state machine releases an NM-managed TAP from
its bridge 58-87 ms after (re)enslavement (~50% while the tap has not
been assumed yet; the port stays up, no GNS3 process logs anything).
Root-caused with a server-free minimal harness (one uBridge, one tap,
one bridge, an addif/delif loop) and a single-variable NM A/B; the
NetworkManager side is explained by three of its commits (assumption
recheck on external enslavement, master removal during activation,
external changes not touching assumed slaves).

Records: forensic signatures (ifindex unchanged, flags 0x300 cleared,
heal-on-next-update), the reproducer, the verified host fix
(unmanaged-devices conf.d snippet: 19-20/40 -> 0/40, and the e2e
scenario green with test-side workarounds disabled), scope and history
(2016 issue #440 veth incident; the 2026-10-01 ethernet-switch
incident previously misattributed). Cross-linked from the L2-anchor
spec and the switch feature doc.
2026-10-06 00:21:11 +08:00
YueGuobin
1c85befbab
test(e2e): take the switch anchors out of NetworkManager's reach
The esw kernel fast-path scenario deletes and re-creates a link, which
re-enslaves the peer anchor — and on NetworkManager hosts NM's netdev
state machine can release an NM-managed TAP from its bridge some 60-70 ms
after enslavement, ~50% of re-enslavements, while the tap is in NM's
not-yet-assumed state. The freed port stays administratively up and no
GNS3 process logs anything: the membership assert sees the join, then the
ping measures a silent blackhole (this was the line-277 e2e failure).

Root-caused with a server-free harness (one uBridge, one fresh tap, one
bridge, an addif/delif loop): the same tap frees 20/40 while NM-managed,
0/40 after `nmcli device set <tap> managed no`, 20/40 again when
re-managed. Docker's veth host ends are not NM-managed, which is why only
TAP anchors ever showed it — including the "anchor lost its bridge
membership after a ports_mapping update (TAP peers only)" incident from
the switch's development, whose conclusion is corrected in the docs.

Unmanage the anchors once the routers have booted, before the scenario
re-enslaves them; document the failure signature and the host-side remedy
(per-device nmcli call, or NM unmanaged-devices configuration) in the
L2-anchor spec and the switch feature doc.
2026-10-06 00:21:11 +08:00
YueGuobin
dc5e2d90ba
fix(iou): keep BridgeNIO filters across the NIO update route
Upstream's mypy pass narrowed the filter copy to UDPNIO only (the
Ethernet/TAP members of the union have no filters field). BridgeNIO —
the kernel-datapath NIO — carries filters too, so on an IOU kernel link
every PUT of a link's filters cleared them and applied nothing: netem
never landed on the anchor (e2e test_iou_kernel_datapath caught it).

Narrow to (UDPNIO, BridgeNIO): still avoids attribute access on the
models without the field, keeps both filter-carrying types.
2026-10-06 00:21:11 +08:00
YueGuobin
7d04d08dba
fix: repair imports lost in the 3.1 rebase and meet the new lint baseline
The rebase onto upstream 3.1 (mypy + ruff I001/F401 waves) left the tree
short of the newly enforced baselines in three ways:

* contextlib / UbridgeError imports dropped as false duplicates during
  conflict resolution left NameErrors in iou_vm and qemu_vm (F821);
  the Dynamips router grabbed the compute-level NIOTAP instead of the
  hypervisor one, breaking TAP anchoring (test failure).
* unused imports (KERNEL_UNSUPPORTED_FILTERS, UbridgeError, List) and
  unsorted blocks left behind where the stack's later commits moved the
  last usage elsewhere — ruff I001/F401/RUF022 now enforce these.
* mypy, now baseline-free: cache dict annotations in tc_probe, the
  locators candidate list, the resolved-params None branch in the MCP
  runner, and validate_bpf_syntax keeps the upstream Dict[str, Any]
  return (the strict Optional[str] was wrong for the "valid" bool entry).

The e2e raw-frame probe resolves its compiler via shutil.which (S607)
and zips with strict= (B905); the remaining subprocess call is S603 by
nature and gets a per-file ignore like other test files.

ruff check/format clean, mypy at upstream parity (no new errors),
full unit suite 2167 passed.
2026-10-06 00:21:11 +08:00
YueGuobin
e13e5f3804
fix(tools): merge same-device entries before device command execution
A batch carrying the same device twice lost all but the last entry's
commands: the per-device command map kept only the last entry while
result processing iterated every original entry, so each row reported
the last entry's output under its own command list.

c3c78f99a consolidated the merge into the MCP template renderer, but
that only runs when a template is passed — the MCP direct-commands
path and gns3-copilot's direct _run path were left uncovered.
Normalize once in each tool's _run (single point covering all entry
paths, restoring the c42b3a59b coverage) and keep the renderer merge
as boundary normalization. Blocked-command info now also accumulates
across same-device entries instead of last-winning in the per-device
map.
2026-10-06 00:21:11 +08:00
YueGuobin
d0b0cdbd38
fix(compute): a passive absorbed anchor no longer resets the switch's tc state
The peer end of an Ethernet switch fast-path link is a NIOBridge with no
bridge of its own (the switch absorbed its anchor into the switch bridge).
Its update reconcile still ran the impairment pass with the empty filter
set the controller routes to that end — and an empty set is a plain
`tc reset` on the shared anchor, silently wiping the netem/cls_bpf/eBPF
the switch had just applied. Any link update did it (filters, markers,
suspend, capture start/stop), and the outcome depended on the link's
endpoint order: UdpLink.update() PUTs node1 then node2, so with the
switch first its apply ran first and the peer's reset ran last (API
green, data plane unimpaired), while with the peer first the reset ran
first and the filter survived. That order dependence is also why the
shipped e2e never caught it — it only created peer-first links.

Ownership is now explicit: only the end that owns the anchor's tc state
applies the impairment reconcile (_owns_anchor_impairments — a NIOBridge
with `bridge is None` is a passive carrier, the same idiom the
bridge-delete path already used). Markers, capture and the suspend
carrier still apply on both ends: markers reconcile per process, so
neither end can strip the other's, and the carrier converges by
agreement.

Red/green: two new docker unit tests fail on the unfixed tree with
`tc reset "<anchor>"` and pass after; the switch e2e also creates one
link switch-first and asserts the netem qdisc and the RTT hold under
that order (failed with the wipe, passes with the fix). Full unit suite
2110 passed; the switch e2e file passed end to end (378s).
2026-10-06 00:21:11 +08:00
YueGuobin
7a9a514ea3
docs: kernel-datapath family review pass — accuracy fixes and pinned claims
A correctness read-through of the five delivered datapath documents, plus
the two code comments that carried the same stale vocabulary.

Datapath selection: the VendorDockerVM selector is `console_type=docker_exec`
alone (`GNS3_SKIP_INIT` and friends are behavior knobs of the chosen class);
the cross-compute and anchor-less fallbacks are named for what they are; the
Ethernet-switch endpoint is the documented exception to the anchoring rule,
and the IOL runner container is called out as the Docker node that does not
anchor on a veth.

Filter vocabulary: the stateful eBPF program evaluates four modes
(nth → quota → window → flow), GNS3 exposes three of them, and window_drop
joins the kernel-only list; the correl suffixes gate on the `rate` token as
the extension-build marker.

group_fwd_mask: the 0xfffd port mask is applied inside uBridge's `brctl addif`
itself, not as a separate command — the link-creation sequences and the
operation tables no longer show an arrow the server never sends. Same class
of fix for `link set up` on the IOL anchor (addif already brought it up; the
command only pushes a suspended anchor back down) and for the QEMU carrier
replication (QMP `set_link`, gated by `replicate_network_connection_state`).

Lifecycles and stop orders re-worded around the invariant rather than the
node type: the fd holder must release before the devices are deleted, which
is QEMU's process-first order, IOU's uBridge-first order and Dynamips'
hypervisor-NIO-first order. Deferred wiring for a stopped endpoint is
documented as the common path it is, and the Dynamips "stop is not a close"
note now says what recovery from a dead hypervisor actually requires.

Dynamips specifics: a c7200 needs an adapter in slot 1 (slot 0 is the fixed
I/O slot), the tap-fd direction is stated correctly (writes inject into the
kernel, reads return what the kernel transmits), and the anchor registry is
name-keyed against the controller's port matrix by convention, not derivation.

IOL port bridge naming: `bridge{bay}` on unit 0, `bridge{bay}_{unit}`
elsewhere; the leg swap happens on link delete/re-create or a datapath
switch, while suspend is an in-place NIO update and a project reopen is a
fresh build.

Verified and Roadmap sections: the in-repo e2e suite is distinguished from
the isolated-instance development harness (IOU), assertions are stated with
their bounds and capability gates, and the roadmap entries collapse into the
one remaining project — VXLAN/GENEVE for cross-compute links.

Code comments: `flow` added to the documented mode order in the eBPF apply
path, and the `nio_bridge` module docstring corrected on which filter types
run on the kernel datapath (bpf via cls_bpf, frequency_drop/quota/window_drop
via the eBPF classifier — neither is relay-only any more).
2026-10-06 00:21:10 +08:00
YueGuobin
74010b9abd
docs: dynamips kernel-datapath diagram suite, unified frame-path form across the family
The dynamips doc gets the full suite: anatomy (anchors reached by the
hypervisors' TAP fds, uBridge's AF_PACKET-tap / tc-qdisc sidebands), a
one-way copy-numbered relay comparison (six kernel/user crossings per
way against the kernel datapath's two TAP-fd legs), a staged walkthrough
(reserved-range diamond, egress clsact chain, capture/markers observation
sideband, copies 1-2) and a control sequence of real commands in real
order (hypervisor fd first, capture before markers, carrier last; router
handlers, not computes, as the per-node participants).

The four sibling frame paths are unified on the same form: netem qdisc
node labels, one capture/markers node outside the stages with AF_PACKET
tap edges anchored at ingress and post-chain egress, copy numbering
wherever the trace crosses the kernel/user boundary (qemu's two TAP-fd
legs, iol-docker's six socket/TAP legs), stage subgraphs with role-noted
titles everywhere (iol-docker restructured from its flat chain, iou's
chA/tapA variants folded into the canonical shapes).

The documentation skill records the rules as v3.1.0: four diagram kinds
(anatomy / comparison / control sequence / walkthrough), one step per
node, dashed sidebands per role with meaningful direction, numbered
crossings when copies are the point, glossary before the first diagram.
2026-10-06 00:21:10 +08:00
YueGuobin
d0773aabea
docs(iou): align the kernel-datapath doc with the delivered implementation
Sequence diagram: drop the phantom "port fwd_mask 0xfffd" step — uBridge
sets the per-port mask inside brctl addif and gns3-server never sends it.
Frame path: explain the ingress/egress mechanism in prose (the TAP-fd
write lands at the sender's anchor ingress, where nothing is installed;
the bridge's forward out the far anchor is the egress transmission the
tc chain sits on), and group the netio mailboxes into a kernel subgraph
matching the relay diagram. Suspend: a down anchor answers EIO on the
TAP fd in both directions (the section B hardening), not reads-block.
Term table: the anchor shape is per Ethernet bay (4 units per bay), not
the default 2x4. Link operations: carrier up is applied last, matching
_attach_kernel_nio. Link-local: note the verification matrix runs on the
Docker datapath. Reflow paragraphs to the soft-wrap convention.
2026-10-06 00:21:10 +08:00
YueGuobin
8bca2c70f8
docs: qemu kernel datapath doc in the family diagram style
Term table, QEMU↔QEMU topology with role-suffixed anchors and
frame-copies/qdisc-install dashed edges, relay copy-count diagram,
boxed frame path, link creation sequence; prose reflowed one
paragraph per line.
2026-10-06 00:21:10 +08:00
YueGuobin
e04552cdb0
docs(iol): IOL runner kernel datapath — diagram suite and glossary
Replace the ASCII overview with mermaid diagrams per the documentation
standard: a glossary table first, then the two-container architecture
(port bridges inside their uBridge process blocks, the per-link Linux
bridge circled, dashed sidebands split by direction — frame copies flow
to uBridge, qdisc installs to the anchor), a copy-counted relay diagram
(six kernel/user crossings each way, datapath-invariant — swapping
datapaths only changes the middle two), the frame path with the
reserved-range diamond, and the link creation sequence. Details moved
from the old bullets into per-diagram prose; paragraphs reflowed to one
per line.
2026-10-06 00:21:10 +08:00
YueGuobin
2e29d053cf
docs: docker kernel datapath — diagram suite, glossary and refreshed configuration
Replace the ASCII art with a mermaid suite (steady-state topology with
namespace boxes and sideband edge semantics, one-way frame path with
per-object boxes, link-creation sequence, full relay path with the four
kernel/user copies), add a term glossary ahead of the diagrams, reflow the
prose to soft-wrap, and refresh the stale parts: the configuration section
now describes enable_kernel_datapath as the global switch it already is,
with every anchorable node type landed and linked to its feature doc.
2026-10-06 00:21:10 +08:00
YueGuobin
df79773bc0
test(e2e): live link-local matrix on the kernel datapath
Two Alpine containers on a kernel link, a static raw-Ethernet probe built
at run time and docker-cp'd in (the image has no raw-frame tool; DockerDaemon
gains the cp), injecting per-destination-MAC frames from guest 1 while
guest 2 counts arrivals: ordinary multicast, STP, LACP, 802.1X and LLDP must
cross, PAUSE must not (the documented kernel limit). Asserts the anchors'
sysfs brport group_fwd_mask reads 0xfffd through the server's own wiring
(int-parsed: newer kernels print it hex), that the bridge level stays 0, and
that a container stop/start replays the mask with LACP surviving. Negative
control is the uBridge suite's setportgroupfwd 0 case.
2026-10-06 00:21:10 +08:00
YueGuobin
f03c94db6d
docs: link-local frame transparency on kernel links — spec and per-datapath sections
A Linux bridge standing in for a cable does not forward the IEEE 802.1D
reserved range (01:80:c2:00:00:00-0f) by default, so LACP/LLDP/802.1X never
crossed a kernel-datapath link while the UDP relay carried them fine. uBridge
now opens every enslave'd port's IFLA_BRPORT_GROUP_FWD_MASK to 0xfffd at
brctl addif time (branch feature/bridge-tap-l2only, commits 0284500/03a18f1/
cf4e494), which the six kernel-datapath feature docs now document as a
section: STP, LACP, 802.1X and LLDP/DCBX cross; 802.3x PAUSE / PFC stays
dropped — br_handle_frame()'s case 0x01 discards it unconditionally, no mask
can enable it, and PFC labs must use a relay link. The full contract, kernel
references and requirements live in the new design spec, marked landed.
2026-10-06 00:21:10 +08:00
YueGuobin
86651604c4
test(e2e): relay capture and markers on both relay engines
The relay engines' observable paths had no live coverage: every
capture/start call in the suite sat on a kernel anchor (AF_PACKET
capture start_kernel) and markers were only asserted on marker
add_kernel. The two relay engines' own command shapes were unit-tested
only:
  * bridge engine  - bridge start_capture / bridge add_packet_filter ... mark
  * iol_bridge engine - iol_bridge start_capture / iol_bridge add_packet_filter ... mark

Docker relay control (bridge engine, 1 passed 33s): the capture start
REST call writes the ICMP exchange to a pcap off the relay bridge; a
mark filter attached with capture_node_id n1 signals exactly 10
marker.match events over the dedicated marker WS for 5 pings (5
requests + 5 replies, both directions), carrying node_id/link_id/tag and
the busybox frame length, writes exactly those 10 frames to the marker
pcap, and deleting the marker takes the pcap with it.

IOU relay control (iol_bridge engine, 1 passed 173s): the same two
checks on iol_bridge start_capture and the port's mark filter, driven
by the real IOS ping through the fabric socket relay.

Not duplicated in the ESW/VPCS/cloud relay controls: they issue the same
uBridge bridge commands through their own call sites, which stay covered
by unit tests; the IOL container relay goes through the same iol_bridge
engine already exercised here.
2026-10-06 00:21:10 +08:00
YueGuobin
ff41a54364
test(e2e): assert the §E.2 idle-silence window in every kernel scenario
The L2-anchor spec's behavioral guarantee — an idle link stays silent for
5 s once the enslavement burst settles — only ran in the Dynamips and
Docker suites. It now also runs in QEMU, IOU, the IOL container, the
switch fast path and the switch cascade, which covers every host-device
creation path:

* tap create (Dynamips/QEMU/IOU anchors), docker create_veth (Docker host
  ends; the cascade's race-loser end is hardened after create by the
  server), bridge add_nio_tap (the IOL container anchor, the fifth
  creator), and the brctl switch bridges, whose multicast snooping is off
  precisely so the bridge role can reach silence;
* each scenario silences its guests first (IOS interfaces shut through
  the console; Docker guests' links down) and re-verifies the ping after;
* the cascade measures 8 devices at once (four absorbed router anchors,
  both cascade ends, both switch bridges).

IOL 158 s, cascade 293 s, ESW fast path 378 s, QEMU 428 s, IOU 308 s —
all green.
2026-10-06 00:21:10 +08:00
YueGuobin
1f544c96a5
test(e2e): classifier spot check and filter persistence on TAP anchors
The filter matrix (cls_bpf, the eBPF classifier) only ran on Docker veth
host ends; the IOU and IOL-container scenarios now spot-check it on their
TAP anchors and prove a filter survives a node restart:

* bpf "icmp" drops everything with clsact on the anchor, both ends, and
  clears back to an empty qdisc;
* frequency_drop 3 (the eBPF every-nth mode) measured 56 % round-trip
  loss (1 - (2/3)^2 = 55.6 % expected) in both scenarios;
* the IOU test applies delay 100 before a router stop/start: the fresh
  anchor gets netem back (tc level) and the restarted router's pings
  measure RTT >= 150 ms until the filter is cleared (a re-attach that
  dropped the filter would show ~100 ms).

IOU file 492 s, IOL file 182 s, both green.
2026-10-06 00:21:10 +08:00
YueGuobin
9d937802d5
test(e2e): relay filters shape real traffic on both relay engines
Extend the docker and IOU relay negative controls with the filters the
relay itself supports (delay, frequency_drop, bpf), asserted through real
ICMP — the first live coverage of the relay filter path:

* bridge engine (docker): delay 100 grows the RTT by ~200 ms and comes
  back on clear; frequency_drop 2 kills the round trips (the filter counts
  every packet crossing the bridge, both directions sharing one counter,
  so on an alternating ping stream one whole direction dies; verified
  against uBridge directly), frequency_drop -1 blackholes, and bpf drops
  by libpcap expression ('icmp', then 'greater 150' discriminating by
  frame size).
* iol_bridge engine (IOU): the same delay and frequency_drop effects
  through the port's iol_bridge filter list, plus the available_filters
  spot check.

Docker 33 s, IOU 168 s, both green.
2026-10-06 00:21:10 +08:00
YueGuobin
4fc0398423
test(e2e): live coverage for markers on the kernel datapath
Two Alpine containers on a kernel-only link with traffic-insight markers
attached to a veth anchor (marker add_kernel), observed on the dedicated
marker WebSocket via a new harness piece (WebSocketCollector, a sync
facade over websockets' sync client). Real ICMP proves: the marker.match
identity is complete (filter/tag/link_id/capture node/ts/len and the
tx/rx direction set of a ping), the main project channel carries no
matches, the BPF and direction are enforced (a non-matching sibling and
a tx-only marker each see exactly their traffic), the host pcap holds
exactly the matched frames, pausing silences one marker while a sibling
keeps counting (silence is never a dead channel), tag replay serves the
paused tag's timeline with display-filter/link/window narrowing and
refuses with 409 while capturing, and deleting a marker takes its pcap
and signals with it.
2026-10-06 00:21:10 +08:00
YueGuobin
ad39b22ab4
test(e2e): live coverage for the switch cascade kernel datapath
Two switches cascaded through the link-owned veth pair, two c7200
routers per switch, real ICMP crossing both bridges: the pair is born
UP with exactly one end per switch bridge, a dot1q trunk cascade
carries two VLANs at once, the symmetric access/trunk mode flip on the
cascade ends isolates and revives a VLAN in place (asymmetric flips are
semantically broken — the access side egresses untagged into the far
trunk's native PVID), a delay filter lands as netem on both ends (one
per direction, RTT ~= 2x delay), suspend admin-downs both ends, and a
link delete/re-create retires the whole pair and mints a new one under
the new link id — zero host residue after the project goes.
2026-10-06 00:21:10 +08:00
YueGuobin
cb7f74c77b
fix(esw): harden the cascade end the create race leaves unhardened
docker create_veth hardens only its FIRST end (the Docker shape: host
anchor plus a container leg that keeps its L3 life). A cascade end is
that second, unhardened end whenever the peer switch's create won the
race, and the kernel gives it an IPv6 link-local — a silent L3 identity
on the switch fabric (ubridge-l2-anchor-spec §E). Each switch now
hardens its OWN end with 'link l2only <end> on' once the pair exists,
whatever branch brought it there (fresh create, lost race, reuse);
idempotent, and best-effort with a warning on a uBridge without the
command.
2026-10-06 00:21:10 +08:00
YueGuobin
6e36b58a5d
fix(compute): refuse a duplicate port NIO and wire before bookkeeping
The compute-side backstop of the controller's duplicate-port guard
(keep-port-links + live-port bind made the controller guard reliable;
this is the layer under it). IOU and Dynamips bound the adapter NIO
before the uBridge/hypervisor attach: a failed attach left the port's
binding replaced — the link that still owned the port lost its handle
and its host state leaked (the IOU EBUSY incident's 8 orphan TAPs).
Dynamips was worse: its wiring idempotence check silently skipped the
attach on a second binding and rebound only the bookkeeping. A port
that already carries an NIO is now refused, and both types bind the
NIO only after the wiring succeeded.
2026-10-06 00:21:10 +08:00
YueGuobin
6839b279be
docs(qemu): record the in-repo e2e coverage 2026-10-06 00:21:10 +08:00
YueGuobin
4fd7e1aa07
test(e2e): live coverage for the QEMU node kernel datapath 2026-10-06 00:21:10 +08:00
YueGuobin
928757542e
fix(controller): bind and release link members on their live ports
Link.delete cleared the port link through the port object stored at
add_node time; a node update since then rebuilt the port list, so that
object was stale and the live port kept its link — the next link on the
port was refused with "Port is already used". Masked until now by the
older rebuild bug (c2d68a65d): links never survived a rebuild, so the
guard never fired and the stale clear never mattered.
2026-10-06 00:21:10 +08:00
YueGuobin
1e999d916d
fix(controller): keep port links across a port-list rebuild
Node._list_ports() rebuilds every Port object, and it runs on each node
update — including every compute node.updated notification (a node
reporting started is one). The links are unaffected by that rebuild, but
the fresh port objects started out with link=None, so after any node
update the "Port is already used" guard in Link.add_node stopped matching:
a second link on an occupied port reached the compute, where the port's
NIO was replaced before the attach failed on the already-bound anchor,
leaving the link half-torn-down (on IOU, deleting the project afterwards
left the node's anchor TAPs and a per-link bridge on the host).

Carry the link back-references over by (adapter_number, port_number),
first match wins, mirroring get_port; a port that still exists keeps its
link. Regression tests pin both the reference (test_node) and the guard
(test_link: the duplicate link is refused after a node update).
2026-10-06 00:21:10 +08:00
YueGuobin
a0d76b30c0
docs(iou): record the in-repo e2e coverage
The Verified section only described the /tmp driver with a fake IOU
image; the repository suite now carries the same scenario with real
routers on a real L3 image.
2026-10-06 00:21:10 +08:00
YueGuobin
aaabf0409d
test(e2e): live coverage for the IOU node kernel datapath
Two real IOU routers on a real L3 image (the image's own IOL fabric, the
real IOS CLI on the server's telnet console), driving the anchor TAPs the
kernel datapath builds on: anchors born with the node (one per Ethernet
bay/unit), real ICMP through the [fabric <-> TAP] port bridge, netem
delay, suspend, capture, link delete/re-create, node stop/start rewiring,
the serial link staying on the relay (serial bays never anchor) and a
relay negative control on an isolated instance. Every scenario ends with
no anchors, bridges or tmpdirs left behind.

harness: create_iou_node/iou_images/iou_anchor_name, and the IOS console
helper is now ios_console (dynamips, IOU and the IOL containers share it).
2026-10-06 00:21:10 +08:00
YueGuobin
765d706ec4
docs(docker): record the in-repo e2e coverage
The feature doc's Verification section now points at
tests/e2e/test_docker_kernel_datapath.py and what the suite pins down
(anchors/bridges, the filter matrix through real traffic, suspend,
capture, the stop/start and delete/re-create beats, the relay control and
the relay->kernel reopen upgrade), including the digest-pinned image the
scenarios share.
2026-10-06 00:21:10 +08:00
YueGuobin
897f50e430
test(e2e): live coverage for the standard Docker node kernel datapath
Two real Alpine containers on an isolated instance, addressed the way a
GNS3 user does it (the node's persistent /etc/network/interfaces, applied
by init.sh) and driven through the Docker daemon socket the server itself
uses. Four scenarios:

* fast path: the per-link kernel bridge enslaves exactly the two veth host
  ends, real ICMP crosses, the L2-anchor spec E.1/E.2 assertions hold
  (idle silence measured with the guests' links down -- a container's own
  boot-time IGMP report re-fires ~10 s later, so silencing is not just
  IPv6), netem delay, suspend/resume, capture, link delete/re-create,
  container stop/start (the veth host ends die with the adapter and are
  rebuilt from the NIO), zero residue on project delete;
* filter matrix, one type at a time, each asserted through real traffic
  and its tc state: netem core (delay, jitter+distribution, loss, corrupt,
  duplicate), the extensions (reorder+correl+gap, seed+limit, gemodel,
  rate), cls_bpf match-drop including a byte-size discriminator, the eBPF
  classifier (frequency_drop every-Nth and -1, quota byte-cap cutoff,
  window_drop single outage with per-seq pass/drop/pass evidence), plus
  available_filters offering the kernel-only types;
* relay control: no per-link bridge, the kernel-only types hidden;
* relay->kernel reopen upgrade: the datapath choice is flipped across a
  server restart and the project reopened -- the link is rebuilt on the
  kernel datapath and forwards.

The harness gains a Docker guest driver (a stdlib Engine API client over
the daemon socket: exec with stream demux, container id by deterministic
name -- the controller's node payload does not carry it, image inspect),
a digest-pinned image helper (cache hit = no network, miss = pull through
the server's own route, neither = skip) and Server.restart() for the
reopen scenario (which also needs the local compute's capabilities to be
up before a project open is accepted).

Live: 4/4 scenarios, 142 s for the file; full e2e suite 10/10 (1503 s);
unit suite 2149 passed; ruff clean. The matrix measures loss with 20
packets at 0.2 s spacing (143 s -> 58 s) and the jitter RTT assertion
keeps statistical margin (per-direction N(50,20); 10 packets, >= 65 ms).
2026-10-06 00:21:10 +08:00
YueGuobin
e0329ceee6
test(e2e): give the dynamips routers a detected idle-PC
Without one every c7200 busy-waits a full CPU core for the whole scenario
-- the tests create raw nodes and no template supplies a value (measured:
constant ~100 % per router, up to ~6 minutes per dynamips scenario).

harness.dynamips_idlepc() detects a value through the controller's
auto_idlepc endpoint (the GUI idle-PC finder path: a throwaway project
boots the image, candidate values are validated by measuring actual CPU
usage) and caches it in ~/.cache/gns3-e2e/idlepc.json keyed by the image
checksum, so the cost (~100 s here) is paid once per image, not per run.
GNS3_E2E_IDLEPC overrides detection; any failure returns None and the
scenarios run without one rather than fail on a CPU optimisation.

All four scenarios that boot c7200 routers (dynamips kernel/relay,
ethernet switch kernel/relay) pass the value at creation. Live: detected
0x60189234 for the M11 image (238 s first run including detection); the
routers drop from ~100 % during boot to 3-8 % steady (sampled per
process), and a re-run reports the cached value with no detection.
2026-10-06 00:21:10 +08:00
YueGuobin
f13da59f23
docs(ubridge): the fifth L2-only creator, and the call site that used it
The spec's B table grows its fifth row: bridge add_nio_tap mints a
transient TAP when the name is free and hardens it at creation, while an
attach to an existing device (possibly a user-owned TAP with addresses)
is left untouched. The rule paragraph spells out attach-is-not-a-creation.
Delivery: uBridge fb72758, closing the gap the new e2e assertions found
(live: the switch's gns3*-0 relay TAP carried fe80::). The server issues
no link l2only of its own -- the switch call site now documents that the
TAP is minted-and-hardened by add_nio_tap, and the ESW feature doc
describes the uBridge-side close instead of a server workaround.
2026-10-06 00:21:10 +08:00
YueGuobin
30cb7119e5
test(e2e): assert the L2-anchor spec's server-side guarantees
The spec's gns3-server alignment section promised three assertions the
suites never carried; they land in harness.py and run in every kernel
scenario (plus the relay controls' anchor checks):

- E.1 assert_pure_l2: anchors and bridges carry no L3 identity (addrgenmode
  none, no address in /proc/net/if_inet6, no ip -o -4 addr) -- the kernel
  scenarios' anchors and per-link/switch bridges, relay-mode anchors and
  the switch's relay TAPs alike (hardening is a creation-time property,
  not a datapath one);
- E.2 assert_idle_silence (Dynamips suite): guest interfaces shut to
  silence IOS's own CDP/keepalives, settle 2.5 s, then a 5 s window of
  zero traffic over both anchors and the per-link bridge;
- assert_forwarding: every attached port reports brport/state == 3 -- a
  bridge left DOWN keeps its ports disabled and forwards nothing, with no
  error anywhere; the assertion is the only signal.

harness.l2only_supported() probes a throwaway uBridge once (link l2only
against a nonexistent device: 208 = command present, 202-Unknown command
= old build); without the command the assertions skip -- the spec's
degradation contract, mirroring the tc-capability skip.

The first live run caught a fifth-creator gap: the switch's relay port
TAPs (minted implicitly by bridge add_nio_tap) carried a live fe80::.
Delivered uBridge-side per the spec (fb72758: the transient TAP is
hardened at creation, attaches untouched); the server needed no change,
and the relay-control E.1 check is that creator's end-to-end coverage.
2026-10-06 00:21:10 +08:00
YueGuobin
02340f2ed3
docs(iol): record the IOL runner container kernel datapath
The feature doc covers the architecture (one irreducible userspace hop
on the unix-socket leg; the link segment kernelized through the port
bridge's swappable TAP leg), the capability gate, the anchor lifecycle,
the swap flow, the inherited capture/markers/filters, the relay
fallback and the live verification — including the ensure-then-add
lesson (tap create is strictly create-only).

The other datapath docs' roadmaps catch up: the IOL runner container
has landed (and the ethernet-switch doc's stale 'cascade deferred'
exclusion from d2f8aba58 is corrected while there); the docker doc's
adapter table no longer rejects IOLDockerVM.
2026-10-06 00:21:09 +08:00
YueGuobin
72aa379e87
test(e2e): live IOL runner container kernel datapath suite
Two real iol-xe containers (Cisco CML iol-runner images, real IOS-XE on
the telnet console) against an isolated instance, the real uBridge with
the swappable bridge TAP leg:

* kernel link reports kernel_datapath; anchors exist from node start
  and the per-link bridge enslaves exactly the two of them;
* real ICMP crosses the [unix <-> tap] port-bridge relay; delay 100
  lands as netem on both anchors with the measured RTT growing >= 150 ms
  and returning < 50 ms when cleared;
* suspend admin-downs the anchor and kills the traffic; resume
  restores; AF_PACKET capture writes a real pcap;
* link delete/re-create swaps the port leg out and back in with the
  ping returning; node stop leaves no gx devices and a start recreates
  the whole wiring from the NIO; project delete cleans everything;
* the relay control on a relay-configured instance keeps the link off
  the kernel and still pings (anchors may exist idle — the same
  semantics as QEMU's and Dynamips' anchors: the server flag decides
  attachment, not anchor birth).

Harness: create_iol_router (the appliance's template properties),
docker_images, iol_anchor_name, stage_iol_base_config (the shipped
iol-xe-base.txt copied into the isolated instance's configs dir — the
GNS3_IOL_STARTUP_CONFIG lookup path), boot_wait(straight_prompt=True)
for images that boot from NVRAM straight into the CLI with no 'Press
RETURN' sentinel, and GNS3_E2E_DEBUG=1 to run the isolated instance
with debug logging (the uBridge command stream lands in server.log —
how the ensure-anchor EBUSY was pinned down).
2026-10-06 00:21:09 +08:00
YueGuobin
13c1ffb951
feat(controller): let IOL runner containers take the kernel datapath
_is_unix_socket_docker no longer excludes the IOL runner marker: those
containers anchor on persistent TAPs now (compute side), so they
graduate through _kernel_endpoint_ready's gate instead — both
ubridge_bridge_tap (the swappable leg) and ubridge_tap (the tap module)
strict True, an unreported capability keeping the link on the relay
where it always works. The generic GNS3_UNIX_SOCKET_NIO exclusion keeps
its shape (an IOL runner environment wins the compute's class
selection, so the exclusion must not fire when both markers are set).

The switch fast path names an IOL runner peer's anchor through
kernel_anchor_type — the iol_docker key, never the gv veth namespace.

The 307d676cb regression tests move with the semantics: the capability
matrix replaces the unconditional relay, and the switch-path test also
asserts the gx naming. Its original concern stands — a compute that
cannot serve the anchor lifecycle must never be handed a kernel link
(the switch's deferred join would make it a silent dead cable).
2026-10-06 00:21:09 +08:00
YueGuobin
4b4a0fc35f
feat(iol): anchor IOL runner containers on persistent TAPs
IOL runner containers (IOLDockerVM, GNS3_IOL_RUNNER) had no kernel
datapath: their guest leg is the runner's AF_UNIX socket pairs, so every
link rode the uBridge relay. Their Ethernet bays now own persistent TAP
anchors (gx names, the iol_docker key of the shared naming contract —
born at node start like IOU's, before any link, so the switch fast
path's deferred join can never miss them), and the per-port bridge's
topology leg swaps between UDP (relay) and the anchor (kernel link)
through the frozen stop -> delete -> add -> start contract — the unix
binding never re-binds across a swap.

- iol_docker_vm: KernelDatapathMixin hooks, the anchor lifecycle
  (_prepare_tap_datapath at _start_ubridge: probe ubridge_bridge_tap +
  ubridge_tap, sweep leftover, tap create born down), the kernel attach
  (ensure-then-add — tap create is strictly create-only, IFF_TUN_EXCL,
  so the ensure checks /sys/class/net first: an unconditional create
  answers EBUSY on the device the start loop just made), the release
  (port bridge stop + delete_nio_tap before the shared teardown) and the
  stop order the fd holders dictate (port bridges first, then tap
  delete, then the per-link bridges — all before the hypervisor stops).
- vendor_docker_vm: the port-bridge creation factored into
  _ensure_unix_port_bridge (idempotent on the registered name), keeping
  the NIOBridge rejection for the generic unix-socket containers.
- docker_vm: the three _kernel_veths lookups go through the
  _kernel_host_ifc hook — behavior-identical for DockerVM, and it is
  what lets the subclass route without overriding the binding methods.
- kernel_anchor: the iol_docker key (gx) and kernel_anchor_type, the
  controller-side mapping for naming an IOL runner's anchor on the
  switch fast path (the marker check stays inline: this module sits
  below everything and must not import the controller package).
2026-10-06 00:21:09 +08:00
YueGuobin
c20125c180
feat(compute): probe uBridge's swappable bridge TAP leg
The bridge module gained delete_nio_tap (uBridge's gns3server-integration
contract, section 'bridge — generic NIO relay with a swappable TAP leg'):
a per-port bridge can swap its topology leg between UDP and a persistent
TAP through stop -> delete -> add -> start, which is what lets an IOL
runner container's port bridge carry a kernel-link anchor.

The probe needs no scratch objects and no capabilities: one command
against a bridge that cannot exist — 202 (unknown command) is an old
build, 214 (bridge doesn't exist) is a new one. Cached by binary identity
like every other probe; any failure reports unknown.

GET /v3/compute/capabilities gains ubridge_bridge_tap; the controller's
default capability dict carries it too (the single schema definition, per
the 1e67fbf12 lesson).
2026-10-06 00:21:09 +08:00
YueGuobin
d6851695cc
test: give the argument-less pytest run the e2e-excluding configuration
CI runs the suite through `just test` -> `just unit`, i.e. `pytest` with
no path argument and the repository root as rootdir — which does not load
tests/pytest.ini, so its `addopts = -m "not e2e"` never applied there. The
e2e tests were collected and executed on every CI run and only stayed
green because a runner has no ~/GNS3/images directory: the harness skips
itself. A local run on a machine that does have images went ahead and
booted real servers for twelve minutes instead.

Declare the same policy in pyproject.toml, the file that argument-less
run does resolve, and add testpaths so the package-internal tests
(gns3server/agent/.../tests/) stay collected — `pytest tests` never
reached them either. That shape keeps resolving tests/pytest.ini and is
unchanged.
2026-10-06 00:21:09 +08:00
YueGuobin
90bcb32473
feat(esw): cascade two switches through a veth pair
Switch-to-switch links were the last shape left on the relay: the switch
fast path works by absorbing the *peer's* anchor, and a switch has none —
its ports are its own bridge, and a kernel interface belongs to exactly
one bridge, so two bridges cannot be joined by enslaving anything.

Join them with a veth pair instead: one end per bridge, each side
enslaving its own end and applying its own port mode to it — an access
port on one side and a trunk on the other behaves like the real cable it
models. uBridge's docker create_veth/delete_veth (the primitive every
Docker adapter is already born from) creates and destroys the pair; the
names derive from the link id (utils.kernel_anchor.kernel_cascade_names),
so each side computes its own end and the names survive a restart.

The pair belongs to the link, and the two ends are wired by two
independent uBridge processes — the project-open batch drives different
nodes concurrently — so the protocol is order-free: both sides create the
pair when their own end is missing (one create wins; the loser's failure
is re-checked against the kernel and "the pair is there" is not an
error), and either side's teardown destroys it (deleting one veth end
takes the other with it, so both converge on the same gone pair and the
second delif answers ENOENT). A pre-existing end — this switch restarted,
or a leftover from a previous life of the link id — is reused rather than
recreated, because the other end may still be a live port of the peer
switch; a stale tc qdisc is all a reused end can carry.

No new wire format was needed: a cascade is an anchor NIO on both ends,
the absorbed-anchor case plus the peer end's name. That also corrects the
filter routing — the single-sided rule exists because an absorbed anchor
is one interface, while a cascade has one per switch, so impairments go
to both ends like any other kernel link.

Tests: the wire format, the eligibility flip and the both-ends re-push on
node start (controller); the naming contract (utils); the switch-side
lifecycle — create, reuse, lost create race, delete destroys the pair
(compute routes).

Drive-by: the relay filter test's compute mock carried no awaitable
`put`, so tests/controller/test_kernel_datapath_link.py failed when run
alone while an argument-less full run passed; callers now get one.
2026-10-06 00:21:09 +08:00
YueGuobin
caa054986f
fix(controller): keep IOL runner containers off the kernel datapath
The unix-socket container gate matched GNS3_UNIX_SOCKET_NIO in the node
environment only, but the IOL runner reaches the same wiring through the
GNS3_IOL_RUNNER marker: the compute selects IOLDockerVM on it, and that
class forces the AF_UNIX socket pairs whatever the generic knob says —
and the documented environment for those nodes carries only the marker.
Such a container was therefore handed a kernel link its compute cannot
serve. The plain case failed at link creation (no veth end to enslave);
on the switch fast path it was worse — the switch defers a join against a
missing anchor by design (a link to a stopped node must not fail), so the
cable was silently dead instead of merely slow.

Match both markers, through the shared is_iol_runner_environment helper so
the controller and the compute select the same way. Verified live on a
real server: docker↔IOL-XE and switch↔IOL-XE links stay relay while the
docker↔switch control link still takes the kernel datapath.
2026-10-06 00:21:09 +08:00
YueGuobin
a40503e44b
fix(qemu): release the datapath under the execution lock
QEMU's process monitor calls stop() by itself when the process dies, so an
API stop and a process-death stop run concurrently in the normal case — and
the datapath release sat outside _execute_lock, letting both stops sweep the
adapter TAP map at the same time. Whichever finished first cleared
_kernel_taps under the other's iterator, which died with "dictionary changed
size during iteration" (the monitor's task, so it surfaced as "Task exception
was never retrieved") with the rest of its sweep, the orphan-bridge removal
and uBridge's shutdown skipped.

Keep the previous fix's ordering (process side first, then the devices) and
hold the lock across the release: serialized, the second stop finds uBridge
gone and sweeps nothing. Reproduced deterministically with two concurrent
stop() calls, the first parked mid-sweep — it raised at qemu_vm.py:1659
before the change, and every TAP is swept exactly once after it.
2026-10-06 00:21:09 +08:00
YueGuobin
0b78d313eb
fix(qemu): release the datapath after the process is gone
QEMU is the guest side of every adapter TAP, and uBridge's tap delete
refuses a device another process holds open ('Device or resource busy') —
a best-effort delete whose failure is suppressed. QEMU's stop did the
delete first and stopped the process after, so every node stop or project
delete leaked the persistent TAPs: found by inspecting the host after a
project delete (16 taps left behind, carrier down, no holder), and the
held-tap behaviour reproduced against uBridge directly.

Stop the QEMU process first, then remove the TAPs and the per-link kernel
bridges — process side first, then the devices, the same lesson as IOU's
reverse stop order. Verified live: node stop and project delete now leave
no taps and no processes behind, while a restart recreates the TAPs.

The adapter anchor naming also goes through the shared
utils.kernel_anchor helper, like the other node types.
2026-10-06 00:21:09 +08:00
YueGuobin
c343ce0b8f
feat(esw): wire peer anchors straight into the switch bridge
The Ethernet switch's kernel fast path: instead of a link crossing the
switch's per-port uBridge relay, the switch absorbs the peer's anchor into
its own kernel bridge. The peer's interface (Docker veth host end, QEMU
TAP, IOU port TAP, Dynamips port TAP) becomes the switch port, and frames
cross anchor -> switch bridge -> switch ports entirely in the kernel.

Wire format: the switch end receives nio_anchor ('join this interface to
my own bridge' — the mirror of nio_bridge), the peer end nio_bridge with a
null bridge ('your anchor is bridged elsewhere': carrier, markers and
impairments only — never bridge membership, and never a bridge delete, on
an interface this node does not own). Anchor names come from the shared
naming contract (utils.kernel_anchor), a pure function of node type, id,
adapter and port — the controller computes the peer's anchor while the
node may be stopped, which is what makes links to stopped nodes wireable.

Stopped peers defer the join; after any node of a link starts
(UDPLink.node_started) the controller re-pushes the switch NIO, and the
switch completes — or repairs, when a peer restart replaced its interface
under the same name — the join against the kernel's actual membership
instead of assuming it.

VLAN changes now edit membership in place: only the ports whose mode/VLAN
actually changed are touched, each losing exactly the previous mode's
entries the new mode does not want (the dot1q admits-all entry is one
range operation in both directions), and no port ever leaves the bridge to
change its VLANs. This replaces the old 'reset by re-enslaving' shortcut
(delif+addif on every wired port, added when the ports were the switch's
own TAPs): on absorbed anchors, re-enslaving an interface owned by another
node is where live testing watched links die — anchors occasionally lost
their bridge membership right after a ports_mapping update (later updates
then failed with delif EINVAL), reproducibly with qemu, IOU and Dynamips
peers and never with Docker's veths.

Eligibility: the switch side needs no capability of its own (its bridge
exists by construction); the peer must be able to anchor. Switch-to-switch
cascades stay on the relay — a kernel interface belongs to one bridge, and
joining two bridges needs a veth pair, deferred.

Verified live on a real server: qemu peers through four VLAN cycles with
both anchors enslaved throughout; a mixed switch (2 IOU + 2 Dynamips, four
kernel ports); Docker peers with real ICMP isolating and recovering (with
both anchors still enslaved); VPCS relay ports likewise in place.
2026-10-06 00:21:09 +08:00