fix: UDP port allocation race causing link self-loop (one-way links)

PortManager.get_free_udp_port had an unguarded find-then-add sequence.
A link allocates both ends concurrently (asyncio.gather in
UDPLink._prepare -> two POST /ports/udp) and FastAPI runs the sync
route handler in a threadpool, so both threads could probe the same
'free' port before either recorded it — handing lport == rport to both
ends. uBridge sets SO_REUSEADDR on UDP NIO sockets, so the double bind
succeeds silently and the kernel delivers everything to the last-bound
socket: one node starves, the other echoes to itself.

Make every TCP/UDP allocate/reserve/release path atomic with an RLock,
and rebuild _link_data in UDPLink._prepare so reset() commits the fresh
port pair instead of re-sending the stale, already-released one.

Regression tests: threaded barrier allocation never returns duplicates
(red on the old code, UDP and TCP); reset() leaves exactly one mirrored
NIO pair per side with lport != rport (red on the old code).
This commit is contained in:
YueGuobin committed 2026-08-14 21:31:58 +08:00
1 parent 1cddeb8c3f
commit 350f2b24e7
7 files changed
+285 -89

No files matched your search

+48 -24
View File
@@ -5,25 +5,24 @@ See LICENSE file for licensing information.
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
# Docker Link UDP Self-Loop Bug (One-Way Link)
## Bug Report
**Date**: 2026-08-14
**Severity**: Medium (one-way connectivity, CPU burn from packet duplication; intermittent)
**Status**: Open — root cause not yet pinpointed; workaround reliable
**Component**: Link wiring — `gns3server/controller/udp_link.py` (`_prepare` /
`pop_preallocated_udp_port` in `gns3server/controller/project.py`) interacting with
node/uBridge restarts
**Status**: **Fixed** — root cause found and unit-tested (same day)
**Component**: UDP port allocation — `gns3server/compute/port_manager.py`
(`get_free_udp_port` find-then-add race); secondary: `gns3server/controller/udp_link.py`
(`_prepare` accumulated stale `_link_data` on reset)
## Symptoms
Two Docker nodes (observed with Cisco XRd; likely node-type agnostic) linked on the
Two Docker nodes (observed with Cisco XRd; node-type agnostic) linked on the
same compute cannot ping each other. Packet capture on the link shows only **one**
side sending ARP. The other side's traffic never appears on the link at all.
## Evidence (from a live occurrence)
## Evidence (from the live occurrence)
Captured simultaneously on both nodes' uBridge `bridge1` (see diagnostics below):
@@ -34,23 +33,44 @@ Captured simultaneously on both nodes' uBridge `bridge1` (see diagnostics below)
| B's `ethN` counters | RX ≈ TX ≈ 5000+ — B receives its own transmissions back |
| A's `ethN` counters | TX > 0, RX = 0 — never receives anything |
Conclusion: **B's `nio_udp` rport pointed at B's own lport** — a UDP self-loop. B's
uBridge had restarted ~77 s after A's (node stop/start during the session),
i.e. the link was re-wired at least once.
## Root Cause (confirmed)
Deleting and re-creating the link fixed it immediately (fresh port allocation).
**`PortManager.get_free_udp_port` had an unguarded find-then-add sequence.**
A link allocates the UDP port for **both ends concurrently**
(`asyncio.gather` in `UDPLink._prepare` → two `POST /ports/udp`). The route
handler is a sync `def`, so FastAPI executes the two requests **in parallel
threads**. Both threads ran `find_unused_port` (socket-probing, GIL-releasing)
before either reached `_used_udp_ports.add(port)` — the set add is idempotent,
so no error was raised and **both ends were handed the same port number**:
`lport == rport` on both NIOs, a literal self-loop.
## Root Cause Analysis (narrowed, not final)
Why it was *silent* and *asymmetric*:
- `UDPLink._prepare()` builds mirrored NIO data correctly
(`node1: lport=P1/rport=P2`, `node2: lport=P2/rport=P1`) — the logic itself is sound.
- Suspects for the corrupted runtime state:
1. `Project.pop_preallocated_udp_port()` — the batch project-open preallocation
(link-create performance work) racing with link re-creation;
2. link re-creation racing a node/uBridge restart (commit NIOs to a uBridge that is
being torn down/rebuilt), leaving a stale/self-pointing NIO on one side.
- A deterministic reproduction is still needed: create two Docker nodes + link,
restart one node, then verify the UDP wiring (see diagnostics).
- uBridge sets `SO_REUSEADDR` on UDP NIO sockets (`ubridge/src/nio_udp.c`), so
the second bind of the same port **succeeds** instead of failing with
`EADDRINUSE` — link creation returned success.
- With two sockets bound to the same port, the kernel delivers to one of them
(last bound wins). The node that started later — in the live case B,
restarted ~77 s after A — received **everything**: A's packets *and* its own
transmissions echoed back. The 77 s restart did not cause the corruption; it
only decided which end starves.
- The same-compute condition is part of the trigger: both allocations hit the
same `PortManager` instance (a cross-compute link races two processes and
cannot self-collide).
A second, smaller defect was found while auditing: `UDPLink._prepare()`
**appended** to `self._link_data` but the committed NIOs are always taken from
indices 0/1 — after `reset()` (delete + create on the same object) the stale,
already-released port pair was re-committed and the freshly allocated ports
were leaked.
## Fix
| Change | Where |
|---|---|
| `threading.RLock` making find-then-add (and reserve/release) atomic for TCP and UDP | `gns3server/compute/port_manager.py` |
| `_prepare()` rebuilds `_link_data` from scratch instead of appending | `gns3server/controller/udp_link.py` |
| Regression tests: threaded allocation never returns duplicates (red on the old code); `reset()` commits the fresh mirrored pair with `lport != rport` | `tests/compute/test_port_manager.py`, `tests/controller/test_udp_link.py` |
## Diagnostics (uBridge console is the fast path)
@@ -63,8 +83,10 @@ Deleting and re-creating the link fixed it immediately (fresh port allocation).
2. **Container counters** — `docker exec <cid> ip -s link show ethN`:
TX>0/RX=0 → peer never returns; RX≈TX huge with µs-scale duplicates → self-loop.
3. **UDP sockets** — `ss -uln` (no `-p`; uBridge runs setuid-root so process names
are hidden, ports are visible): a two-node link owns exactly two "orphan" UDP ports.
4. **Workaround** — delete and re-create the link (or stop/start both nodes).
are hidden, ports are visible): a two-node link owns exactly two "orphan" UDP
ports. **One port instead of two = this bug.**
4. **Recovery** — delete and re-create the link (or stop/start both nodes);
with the fix, the corruption no longer occurs in the first place.
Note: a Docker node's in-container `ethN` is a **TAP device** whose file descriptor
lives inside uBridge (the interface is created host-side, then moved into the
@@ -74,4 +96,6 @@ waste time hunting for one in the host namespace.
## Related
- `docs/features/vendor-nos-xrd.md` — troubleshooting table entry pointing here.
- Link-create batch optimization (`controller/udp_link.py`, project-open bulk path).
- Link-create batch optimization (`controller/udp_link.py`, project-open bulk path)
was exonerated: the pool path allocates sequentially in one handler and cannot
self-collide.