Skip to content

fix(node): re-pin connected UDP sockets when the host changes medium - #144

Closed
fr34aky wants to merge 9 commits into
jmcorgan:masterfrom
fr34aky:fix/connected-udp-source-pinning
Closed

fix(node): re-pin connected UDP sockets when the host changes medium#144
fr34aky wants to merge 9 commits into
jmcorgan:masterfrom
fr34aky:fix/connected-udp-source-pinning

Conversation

@fr34aky

@fr34aky fr34aky commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #142.

Targets master and carries @Origami74's watcher (dfc1dd2, his authorship
intact) as its first commit, per review — so LinkWatcher lands with its
consumer rather than entering the public library API with no caller on
master. Rebased onto f149cba; the only file of his this touches is the
watcher itself.

Commits reordered so src/node/netmon/linux.rs never exists in any tree: the
watcher-groups change now precedes the fix, and the fix is one commit that was
already driven from the shared watcher. Verified commit by commit.

The Android cfg widening is in as its own commit (f769414): five predicates
to any(target_os = "linux", target_os = "android"), plus the three platform
tables that claimed Android polls. Confirmed the libc chain rather than assuming
it — PF_ROUTE = AF_ROUTE = AF_NETLINK (16) and AF_UNSPEC = 0 = NETLINK_ROUTE
in the pinned 0.2.189, so the old call really was an unbound NETLINK_ROUTE
socket — and all six symbols the widened arm needs are defined for Android, the
last three in the android module itself. Not run on a device, and I have no
NDK here, so the CI android-check leg is the only compile evidence.

The rx-loop hold is addressed in b1a1043, but not by spawning — that does
not compile. The send needs &mut self for the session counter and the MMP
sender record, and neither Node nor TransportHandle is shared or cloneable,
so the fan-out cannot be moved to a 'static task without restructuring
ownership. A timeout around the send is worse than insufficient: dropping a
partial write_all leaves a half-written frame the peer cannot resynchronise
from.

What it does instead is skip the peers whose send can block. A connectionless
send never awaits the wire — the UDP fast path hands the frame to the encrypt
workers and returns — so the unbounded write_all is simply not reachable from
this path any more. A peer on TCP, Tor, Nym or BLE keeps the periodic heartbeat
it had before this detector existed, so for those peers this is v0.5.0
behaviour unchanged rather than a regression introduced here.

I did not extend it to dropping their stale connections, though that is the
honest analogue of dropping a stranded UDP socket, because the cost is not
symmetric: a Tor peer would pay a fresh circuit every time the fingerprint
moved — and per your interface_addrs() note, the fingerprint currently moves
for things like a docker bridge appearing. That seems worth settling together
with the filter question rather than deciding here.

Covered by a regression test that re-pins an established peer onto a TCP
transport and asserts its heartbeat timestamp does not move. Mutation-checked:
replace the filter with true and the test fails.

All four review actions are now in. Outstanding on my side: the two Android
follow-up issues you asked for.

Symptom

Every established UDP peering black-holes for ~80s when the host changes
transport medium — WLAN↔LAN, Wi-Fi↔cellular — and fipsctl show peers
reports the peer as connected for the first 30s of it, with MMP reporting
loss=0.0%. To an operator it presents as "connected but dead", not as a
disconnect. When the reaped peer is the node's only peer it also loses its
tree position and every routed destination goes with it.

Measured unpatched on a live node: route moved at 20:43:54, reaped at
20:44:24, reconnected at 20:45:16 — 82s, tree position lost on the way
through.

Root cause

open_connected_fd binds the wildcard and then calls connect(2), which
makes the kernel resolve the route once and auto-bind the local source
address to whichever interface carries it at that instant. It never
re-evaluates. The only site that drops those sockets is gated on the
peer's address rotating, observed in the data plane — the mirror-image
case, handled correctly.

A local move produces no data-plane event, so there was nothing to hang a
handler off. That is why it went unnoticed rather than unhandled.

Fix shape

Two halves, because the missing piece was the trigger rather than the
reaction.

The signal is a coarse fingerprint of the host's network attachment: the
source addresses the routing table would pick for an off-link destination,
plus the set of up, non-loopback interface addresses. A handover is not
atomic, so a short debounce coalesces the burst into one event and a
fingerprint that settles back where it started reports nothing. Linux reacts
in milliseconds off NETLINK_ROUTE multicast; every other platform samples
on a timer, which also runs underneath netlink as a backstop — a netlink
socket drops messages under memory pressure and the subscription can be
refused outright in a restricted sandbox. A backend only decides when to
look
; the comparison, debounce and settled-back suppression are shared.

The reaction is to drop the pinned sockets and heartbeat every peer at
once. Dropping is self-healing rather than disruptive: the wildcard listen
socket resolves a route per packet, so sends keep working immediately and a
correctly-bound connected socket is reinstalled on a later tick. The
heartbeat fixes the reverse direction — it carries the new source address, so
the far side re-pins on receipt instead of waiting out its own heartbeat
interval. Nothing here tears a peering down.

The socket drop is Linux and macOS, the two platforms with the connected fast
path; elsewhere the heartbeat alone carries the new address. Bluetooth is out
of scope — an adapter's state is not an IP attachment and this detector
cannot see it.

What this needed from the watcher

One additive change. LinkWatcher hardcoded RTMGRP_LINK, which is right
for the question it was built to answer — "is this interface here?" — and
stays the default: new() is unchanged in behaviour, and so is the interface
binder behind it. It is the wrong subscription here, because a default route
moving between two interfaces that both stay up produces no link message at
all. open_link_socket now takes the mask, new() passes the link group,
and with_groups() takes an explicit one.

Two consequences worth naming:

  • macOS and FreeBSD get event-driven detection for free. PF_ROUTE has
    no group selection and already delivered what this needs; the detector was
    polling there only because it had no way to ask. Reasoned, not measured —
    I have run this on Linux only and have no BSD host.
  • The fallback got simpler rather than more careful. changed() parks
    forever when it has no source and after it abandons a broken one, so
    selecting it against the poll timer degrades to the timer with no
    bookkeeping.

Measurements

Same hardware, LAN and cellular both up, crossing genuinely different public
IPs (fixed line vs cellular):

Switch longest gap
LAN → cellular 0.65s
cellular → LAN 0.58s

link_id and authenticated_at_ms were unchanged across both, so the
peering was absorbed rather than torn down and rebuilt. That distinction is
the point: a reconnect fast enough to look similar is not the same outcome,
and the suite asserts against it rather than against "traffic came back".

Test coverage

New integration suite testing/medium-change/: a multi-homed node whose
default route moves between two live access paths, far peer behind a router.
Wired into both runners — check-ci-parity.sh reports 13 suites, 23 legs
each side.

Three things about the topology are load-bearing, each arrived at by watching
the suite pass when it should not have:

  • The far node is off-link. An earlier version installed an explicit /24
    to the far segment, which outranks the default — moving the default left
    the path to the peer exactly where it was, and every assertion passed
    against a peer that had never moved.
  • The router runs strict reverse-path filtering. Without it both of the
    node's interfaces stay routable, a packet from the abandoned path is
    answered happily, and the stale pin is real but harmless. A real gateway
    drops that packet as spoofed — that is why a medium change black-holes
    traffic in the field, so the lab has to model it.
  • The outage metric counts the window's end as a boundary. ping -D
    writes a line only for a reply, so an outage that never recovers stops
    producing lines and a total blackout scored as zero disruption.

Phase 3 repeats the move with node.netmon.enabled = false and requires
the outage, so the suite demonstrates the regression rather than asserting it
from a changelog entry.

Unit coverage sits in src/node/netmon/tests.rs and src/node/tests/netmon.rs
(20 tests). One is #[ignore]-gated on CAP_NET_ADMIN:
a_route_change_alone_reaches_the_watcher adds a route with no link change
alongside it, and under unshare -rn passes on the egress-path mask and
times out on RTMGRP_LINK. No CI leg currently runs it under unshare, so
it documents the link-vs-route distinction rather than enforcing it — say the
word if you want a leg for it.

Docs

docs/reference/configuration.md gains the node.netmon.* block and its
three keys, plus the matching lines in the complete-reference listing. The
poll interval means two different things by platform — a detection-latency
floor where there is no event-driven backend, a backstop period where there
is — which the entry states outright.

Gates

cargo fmt --check, cargo build, cargo clippy --all-targets -- -D warnings,
cargo clippy --all-targets --all-features -- -D warnings, cargo test
(2424 passed, 0 failed), and ./testing/ci-local.sh --only medium-change
(17/17, suite itself 8/8).

Note

Related but distinct: #130 (peer re-handshaking from a new transport address
stalls). The teardown-and-redial this bug forces is one way to reach #130's
precondition, so fixing this removes a common route into that issue without
addressing it.

@fr34aky

fr34aky commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Does this help Android?

Checked, since Wi-Fi↔cellular is the handover this exists for. It half does, and the
half it misses is silent — writing it up here rather than folding it in, because it is
a different platform and a different claim.

The reaction is already correct. Android is not one of the two platforms with the
connected fast path — transport/udp/io/unix_other.rs compiles it out, and
dataplane/connected_udp.rs is gated on any(linux, macos) — so there is no pinned
socket to drop and drop_connected_sockets_after_net_change resolves to the stub, as
intended. What Android gains is the heartbeat, which is the half that matters for a
phone: the phone is the mobile end, so the stale pin lives on its peer, and the
immediate heartbeat trips that peer's address-rotation path in one RTT rather than at
its next due interval. That works as written.

The signal is where it goes wrong. Android is target_os = "android", not
"linux", so build_wake_source takes the not(target_os = "linux") arm:
LinkWatcher::new()open_link_socket(0) → the PF_ROUTE branch. On Android that
is not the harmless no-op it is on a platform with no route socket. libc's shared
linux_like module defines PF_ROUTE = AF_ROUTE = AF_NETLINK (16), and the protocol
argument AF_UNSPEC is 0, which is NETLINK_ROUTE. The socket opens successfully,
is bound to no multicast group, and never delivers a message.

So on the platform that changes medium most often:

  • is_event_driven() returns true,
  • the node logs Network-change detection: kernel events,
  • and nothing but the 5s backstop timer ever fires.

Detection latency is the poll interval, reported as though it were milliseconds. Verified
as far as a Linux host can verify it — constants read out of libc-0.2.189, and an unbound
socket(16, SOCK_RAW, 0) confirmed never readable — not run on a device.

The fix is a cfg widening in build_wake_source and in watcher.rs's groups /
open_link_socket gating, to any(target_os = "linux", target_os = "android"). If
SELinux refuses the group bind for an untrusted app, LinkWatcher already degrades to
the timer, which is today's behaviour rather than a regression.

Two further Android notes, neither verified on a device:

  • interface_addrs() calls getifaddrs, which bionic implements over RTM_GETLINK
    restricted for apps targeting API 30+. The fingerprint likely degrades to the
    source-address probe alone there. That is the tolerated case the sampler's doc comment
    already describes, and the probe is exactly the signal that moves on a Wi-Fi↔cellular
    switch, so detection should still work, with the address-set half contributing nothing.
  • A poll timer is not reliable under Doze. The durable Android signal is a
    ConnectivityManager.NetworkCallback pushed onto this same NetChange channel — the
    shape the module docs already sketch for the BLE adapter.

None of this is covered by testing/medium-change/ (Docker, Linux) or by the
android-check leg (a cargo check, so the unbound-socket path compiles clean). Say
the word if you would rather the cfg widening rode along here than as a follow-up.

@jmcorgan

jmcorgan commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and thanks for the measurements. The change is good and I want it in. Answering your question first, then the review.

Your question: does the Android fix ride along, or follow?

It rides along, in this pull request, as its own commit. Your diagnosis is right and I checked every part of it that can be checked without a device.

Android is target_os = "android", not target_os = "linux", so it takes the non-Linux arm at src/transport/watcher.rs:135. That arm calls socket(PF_ROUTE, SOCK_RAW, AF_UNSPEC) at :139 and never binds. On Android, libc resolves PF_ROUTE to AF_NETLINK and AF_UNSPEC to 0, so the call is socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE) with no group subscription. It succeeds, so inner is Some, is_event_driven() returns true at :221, and the node logs "Network-change detection: kernel events" at src/node/netmon/mod.rs:437 while actually running on the 5 second backstop.

The data plane is fine. Detection still happens on the timer, and the immediate heartbeat that makes the peer re-pin still runs. What breaks is the report: docs/reference/configuration.md:205 tells the operator that poll_interval_secs is the backstop where an event-driven backend exists, so an Android operator reads 5 seconds as a ceiling when it is the floor. Your module table at src/node/netmon/mod.rs:24-28 and the pull request body both say Android polls, and the code says otherwise.

The reason it belongs here rather than in a follow-up is that widening cannot make Android worse. If the socket or the group bind is refused, open_link_socket returns an error, with_groups sets inner = None at src/transport/watcher.rs:207-211, and build_wake_source falls to the timer at src/node/netmon/mod.rs:434-444. That is exactly today's behaviour, with an honest log instead of a wrong one.

Action. One commit that changes five cfg predicates to any(target_os = "linux", target_os = "android"), and nothing else:

  • src/transport/watcher.rs:75 (the groups module)
  • src/transport/watcher.rs:104 and :135 (the two open_link_socket arms)
  • src/transport/watcher.rs:185-187 (LinkWatcher::new's inner gate)
  • src/node/netmon/mod.rs:429-432 (build_wake_source)

Then update the platform table at src/node/netmon/mod.rs:24-28 so it says what the code does.

One thing to be clear about: this path lands unexercised either way. The android-check leg at .github/workflows/ci.yml:140-148 is a cargo check, so it compiles the code and cannot see a socket that opens and stays silent. Nothing in CI runs on a device. That is a reason to keep the commit to the cfg predicates only. It is not a reason to defer it.

The two other Android items you raised should not ride along, because both are real platform work with a design in them: the bionic getifaddrs restriction under API 30+, and the ConnectivityManager.NetworkCallback push under Doze. Please open an issue for each and I will pick them up separately.

Please retarget this pull request to master, carrying the watcher commit

The base does not need to be a separate merge. feat/link-watcher is the base commit of feat/dynamic-interface-binding rather than a sibling of it, so carrying dfc1dd20 here costs that branch nothing.

There is a concrete reason to prefer this over merging the watcher on its own. src/lib.rs re-exports pub mod transport, and dfc1dd20 adds pub mod watcher under it. Merging the watcher alone would put LinkWatcher into the public library API with no caller anywhere on master. Retargeting lands the consumer in the same merge and lets pub mod watcher become pub(crate) in the same pass. Your authorship and Arjen's both survive, since that comes from the commit author field, not from the pull request.

It targets master, not maint. NetmonConfig::enabled defaults to true at src/config/node.rs:254 and :262-264, which makes this a feature by our maintenance rule regardless of its size. The bug is also not a v0.5.0 regression: it has been present since v0.4.0. So master it is, and I am not going to ask you to re-author six measured commits onto a second branch.

Action. Retarget the pull request to master, and rebase so dfc1dd20 is its first commit.

Two more things before merge

1. Move the heartbeat fan-out off the rx loop.

The Some(change) = netmon_rx.recv() arm in src/node/dataplane/rx_loop.rs awaits handle_net_change(change).await, which awaits heartbeat_all_peers_after_net_change at src/node/handlers/netmon.rs:76, which loops over every peer awaiting send_encrypted_link_message at :116. For a TCP or Tor peer that reaches src/transport/tcp/mod.rs:341-342, which is writer.lock().await and then a bare write_all(data).await. The connect above it is wrapped in a timeout at :381-385; the write and the lock are not. A medium change is precisely the condition that leaves a TCP send window full against a path that has just gone away, so this can hold the rx loop for as long as that write takes.

The socket teardown half is safe, and I want to correct an earlier read of mine that suggested otherwise. clear_connected_udp at src/peer/active.rs:442-445 only drops, and PeerRecvDrain::drop at src/transport/udp/io/connected/drain.rs:105-130 detaches its thread instead of joining, with a comment saying why. The fan-out is the only problem.

Action. tokio::spawn the fan-out. The handler needs no result from it: the failure branch only logs at debug at src/node/handlers/netmon.rs:117-121.

2. Reorder the commits so src/node/netmon/linux.rs never exists in the history.

9a0c7c3b adds that file and 69f128e3 deletes it. A bisect that lands between them gets a second netlink socket beside the watcher, which is the design you already rejected. Squash or reorder so the intermediate state never appears.

One question, not a change request

interface_addrs() at src/node/netmon/mod.rs:582-586 filters on IFF_UP && IFF_RUNNING && !IFF_LOOPBACK and nothing else. Every remaining unicast address joins the fingerprint set, including link-local scope, docker bridges, veth pairs and tunnel interfaces.

Measured on one of my hosts right now, from ip -o addr and ip -o link: of the qualifying addresses, two are the real uplink, three come from Docker (a bridge at 10.128.1.1/24 plus two link-local addresses) and three come from a Tailscale interface. docker0 is currently excluded only because it has no carrier, so starting the first container on it adds two more.

What that means in practice: an ordinary docker compose up moves the fingerprint, raises a change, and the handler drops every peer's connected UDP socket and heartbeats every peer at src/node/handlers/netmon.rs:62-77. It is not an outage, since the wildcard listen socket resolves a route per packet, and MIN_CHANGE_INTERVAL bounds it to once a second. The cost is churn on a container host: up to max_peers drain teardowns and re-creations, plus an info! line each time saying the transport medium changed when it did not.

I am not asking you to fix it here. The obvious narrowing, dropping fe80::/10 and 169.254/16, removes the veth half and leaves the bridge half, because 10.128.1.1/24 is global scope. What I would rather know is what you think the address set is for. Your module docs at :55-59 say it exists to catch a medium arriving or leaving without displacing the default route, and the source address probe already covers every default route move. If that is the whole job, the filter can be much narrower than every up interface.

Follow-ups, none of which hold this up

These are all mine to open unless you want them:

  • mark_heartbeat_sent(now) at src/node/handlers/netmon.rs:114 runs before the send at :116 and regardless of its outcome, so a failed heartbeat still resets the timer. On a medium change, failing sends are exactly what is expected.
  • node.netmon.* is not validated. netmon appears nowhere in src/config/mod.rs. poll_interval_secs: 0 is silently clamped by .max(1) at src/node/netmon/mod.rs:420, and debounce_ms is accepted at any magnitude.
  • Nothing in CI can tell the wide netlink mask from the narrow one. a_route_change_alone_reaches_the_watcher is #[ignore]d at src/node/netmon/tests.rs:349-350 and no leg runs ignored tests. In the docker suite, MC_MAX_GAP_SECS defaults to 5 at testing/medium-change/scripts/test.sh:45 and the assertion is gap <= 5 at :242, which is the same 5 seconds as the poll backstop. A backend subscribed only to LINK sits exactly on the threshold instead of failing. Lowering that threshold costs nothing and makes the assertion mean something.
  • LinkWatcher becomes public library API through the src/lib.rs re-export. pub(crate) costs nothing while it has one caller.
  • The host-global fingerprint cannot see a per-peer route change. preferred_source probes two off-link destinations, so a more specific route that moves one peer without touching the default route leaves the fingerprint identical while the symptom is fully present for that peer. Worth a sentence in the module docs.

What came back clean

The body describes what the diff does and states its own unmeasured claims as unmeasured. All five commits are authored by you, with no assistant attribution anywhere in the subjects or bodies. No new dependencies: the getifaddrs walk uses the libc crate already in the tree. The three unsafe blocks each carry a SAFETY comment stating the invariant, and both sockaddr reads go through read_unaligned. Kernel messages are deliberately not parsed, which is the right call. The CHANGELOG and docs/reference/configuration.md:197-207 are both updated. No commits have landed on master since your branch left it, so the rebase should be quiet.

I did not run anything: no build, no tests, nothing on a device. Everything above is from reading the code at 69f128e3 and from ip on my own host. Your timings of 82 seconds unpatched against 0.65 and 0.58 patched are recorded as yours, not reproduced here.

Once the three items above are in, I will merge it.

Origami74 and others added 5 commits September 6, 2026 10:18
A watcher that resolves when the kernel reports a change to the host's network
links, so callers can react to interface state in sub-second time instead of
polling for it. Netlink `RTNLGRP_LINK` on Linux, `PF_ROUTE` on the BSDs.

Self-contained and unused by anything yet: it lands separately because more
than one caller wants it, and a second netlink socket beside this one would be
the wrong answer to that.

Three properties are the reason it is worth sharing rather than reimplementing,
and each has a test:

- **A sourceless watcher parks rather than fires.** A kernel or sandbox that
  refuses the socket yields a watcher whose `changed()` never resolves, which
  is what makes it safe to `select!` against a poll ticker — the ticker simply
  always wins and the caller degrades to polling with no special case.

- **A zero-length read does not spin.** `try_io` clears readiness only on
  `WouldBlock`, so breaking out of a zero-length read leaves `readable()`
  instantly ready with nothing to read, and the loop never returns `Pending`.
  That starves the caller's `select!` of every other arm — it is not a busy
  loop in the watcher, it is a livelock in whatever owns it.

- **Giving up is sticky.** `changed()` is constructed fresh on every pass of a
  caller's `select!` and dropped whenever another arm wins, so a `pending()`
  inside the future parks nothing beyond the current pass. Without a flag on
  the watcher itself, the next pass re-reads the dead socket, re-counts the
  error and re-logs the give-up warning — once per wake-up, forever.

The messages are deliberately not parsed. An event is a hint to re-ask whatever
question the caller actually has, which is cheap and authoritative; decoding
`nlmsghdr`/`ifinfomsg` to reach the same answer would add a parser whose bugs
would become the caller's bugs.

`RTNLGRP_LINK` carries link state only. A caller needing route or address
events should extend `open_link_socket` with a group mask rather than opening
its own socket — the note is in the module docs so the next caller finds it.
LinkWatcher hardcoded RTMGRP_LINK. That is the right subscription for the
question it was built to answer — "is this interface here?" — and it stays
the default: new() is unchanged in behaviour, and so is the interface binder
behind it.

It is the wrong subscription for a different question. A default route moving
between two interfaces that both stay up produces no link message at all;
`ip monitor` reports zero events in the link group for that change and two in
the route group. A caller watching for a change of *egress path* rather than
of interface presence therefore hears nothing, and falls back to its poll
without any sign that the event source is not covering it.

So open_link_socket takes the mask, new() passes the link group, and
with_groups() takes an explicit one. The groups module names the five values
and EGRESS_PATH combines them.

Off Linux nothing changes and nothing can: PF_ROUTE has no group selection
and delivers every routing message to every reader, so the mask is accepted
and ignored there rather than being made conditional at every call site.

The added tests pin that the wider mask binds — these are all read-only
NETLINK_ROUTE groups needing no privilege, so a failure to bind means a wrong
constant rather than a restricted sandbox, and would otherwise surface only
as a silent downgrade to polling.
An established UDP peer gets its own connected socket for the send fast
path. `open_connected_fd` binds the wildcard and then calls `connect(2)`,
which makes the kernel resolve the route once and auto-bind the local source
address to whichever interface was carrying it at that instant. It never
re-evaluates, and the only code that drops those sockets is gated on the
*peer's* address rotating, observed in the data plane.

So a local medium change had no signal at all. A laptop moving between WLAN
and LAN, a phone between Wi-Fi and cellular: every established peer went on
transmitting from an address the routing table had abandoned, and the peer,
which re-pins to whatever source address it last heard from, kept answering
to that same dead address. The peering stayed marked connected the whole
time and carried nothing, until `link_dead_timeout_secs` reaped it and the
reconnect redid the Noise handshake and the tree position from scratch. On a
live node, unpatched: route moved at 20:43:54, reaped at 20:44:24,
reconnected at 20:45:16 — 82s, and the node lost its place in the tree on
the way through.

A local move is invisible in the data plane, which is exactly why it went
unhandled: there was no event to hang a fix off. So this adds the missing
signal and the reaction to it.

The signal is a coarse fingerprint of the host's network attachment — the
source addresses the routing table would pick for an off-link destination,
plus the set of up, non-loopback interface addresses. A handover is not
atomic, so a short debounce coalesces the burst into one event and a
fingerprint that settles back where it started reports nothing. A backend
only decides *when to look* — the comparison, the debounce and the
settled-back suppression are shared — so platforms land behind the same seam
without touching the reaction.

The kernel backend is `transport::watcher`, the same one the interface
binder uses, asked for the egress-path group mask rather than the link mask.
Opening a second netlink socket beside it would have been two subscriptions
to the same multicast groups, in one process, for the same reason. The
shared watcher is also the better of the two by some distance: it survives a
zero-length read that would otherwise spin a core flat, and it backs off and
gives up on a persistently failing socket rather than re-logging once per
wake-up forever, with a give-up state that outlives the future that decided
it — which matters here, because `wait` is a fresh `select!` on every pass.

Two consequences of taking it. macOS and FreeBSD get event-driven detection
for free, because `PF_ROUTE` has no group selection and already delivered
what this needs; the detector would have polled there only because it had no
way to ask. And the fallback is simpler rather than more careful:
`changed()` parks forever when it has no source and after it abandons a
broken one, so selecting it against the poll timer degrades to the timer
with no bookkeeping at all. The timer runs underneath the kernel source in
any case, as a backstop — a netlink socket drops messages under memory
pressure and the subscription can be refused outright in a restricted
sandbox.

The reaction is to drop the pinned sockets and heartbeat every peer at once.
Dropping is self-healing rather than disruptive: the wildcard listen socket
resolves a route per packet, so sends keep working immediately and a
correctly-bound connected socket is reinstalled on a later tick. The
heartbeat is what fixes the reverse direction — it carries the node's new
source address, so the far side re-pins on receipt instead of waiting out
its own heartbeat interval. Nothing here tears a peering down.

Measured after: a WLAN/LAN switch in either direction costs no reconnection
at all — Noise session, tree position and routes all survive. A scripted
8s LAN outage over 186 pings lost 5, in two gaps totalling 1.0s.

The socket drop is Linux and macOS, the two platforms with the connected
fast path; elsewhere the heartbeat alone carries the new address. Bluetooth
is out of scope — an adapter's state is not an IP attachment and this
detector cannot see it.
Reference entries for the `node.netmon.*` block and its three keys, plus the
matching lines in the complete-reference listing so the block is discoverable
by reading down the reference rather than only by knowing to look for it.

Says what the poll interval actually means on each platform, since it is the
detection-latency floor where there is no event-driven backend and only a
backstop period where there is — the same knob meaning two different things,
which is worth stating outright rather than leaving to be inferred. The
platform table names both kernel sources: `NETLINK_ROUTE` multicast on Linux
and the `PF_ROUTE` socket on macOS and FreeBSD.
The connected-socket fix had unit coverage and a hand-run on real hardware,
neither of which a reviewer can re-run. This adds the integration suite: a
multi-homed node whose default route moves between two live access paths
while mesh traffic is in flight, with the far peer behind a router.

Three things about the topology are load-bearing, and each of them was
arrived at by watching the suite pass when it should not have.

The far node has to be off-link. Reachable only through the router, the
route to it follows node-a's default route, which is what the suite moves.
The first version also installed an explicit /24 to the far segment on
node-a "so it could get there" — that route outranks the default, so moving
the default left the path to the peer exactly where it was. Detection fired,
the sockets were dropped, and every assertion passed against a peer that had
never moved.

The router runs strict reverse-path filtering. Without it the suite proves
nothing: both of node-a's interfaces stay up and both stay routable, so a
packet still sourced from the abandoned path is forwarded and answered quite
happily. The stale pin is real but harmless, and the negative control passes.
A real gateway drops that packet as spoofed because the reverse route for its
source points out a different interface — that is *why* a medium change
black-holes traffic in the field, so it is the part the lab has to model.

The outage metric counts the end of the observation window as a boundary.
`ping -D` writes a line only for a reply, so an outage that never recovers
simply stops producing lines, and the largest interval between two surviving
replies stays one ping apart. A total blackout scored as zero. The control
run is what surfaced it: 16 replies, all of them from before the move,
reported as 0.20s of disruption.

The assertions are about continuity rather than reachability, because a
peering the liveness reaper tore down and a re-dial rebuilt also ends with
traffic flowing. Unchanged link_id and authenticated_at_ms on the near node
say no second handshake ran; a changed transport_addr on the far node says it
re-pinned to the new source; a bounded ping gap says the data plane genuinely
carried through. The third is what stops the first two passing vacuously on a
topology where nothing moved.

Phase 3 repeats the move with node.netmon.enabled = false and requires the
outage, so the suite demonstrates the regression instead of asserting it from
a changelog entry.

Measured on this branch: 0.21s and 0.41s for the two directions with
detection on, against a control that stayed dark for the whole 12s window.

Wired into both runners, so `ci-local.sh --only medium-change` and the
GitHub matrix leg stay the same claim; check-ci-parity.sh reports 13 suites
and 23 legs on each side.
@fr34aky
fr34aky force-pushed the fix/connected-udp-source-pinning branch from 69f128e to a4620eb Compare September 6, 2026 08:29
@fr34aky
fr34aky changed the base branch from feat/link-watcher to master September 6, 2026 08:31
Android is `target_os = "android"`, not `target_os = "linux"`, so it took the
non-Linux arm of `open_link_socket` and opened a `PF_ROUTE` socket. That is a
no-op on a platform with no route socket. On Android it is worse than a no-op,
because libc's shared `linux_like` module defines `PF_ROUTE = AF_ROUTE =
AF_NETLINK` (16) and the protocol argument `AF_UNSPEC` is `0`, which is
`NETLINK_ROUTE`. The call is therefore `socket(AF_NETLINK, SOCK_RAW,
NETLINK_ROUTE)` with no group subscription. It succeeds, so `inner` is `Some`,
`is_event_driven()` returns true, and the node logs "Network-change detection:
kernel events" while nothing but the poll backstop ever fires.

Nothing was broken in the data plane by that: detection still happened on the
timer, and the immediate heartbeat that makes the peer re-pin still ran. What
was broken was the report. The reference says `poll_interval_secs` is only a
backstop where an event-driven backend exists, so an Android operator read
5 seconds as a ceiling when it was the floor — on the platform that changes
medium most often, and the one this detector exists for.

So the five predicates that decide which arm a platform takes widen to
`any(target_os = "linux", target_os = "android")`: the `groups` module, both
`open_link_socket` arms, `LinkWatcher::new`'s inner gate, and the backend
choice in `build_wake_source`. The platform tables in the module docs and in
the configuration reference are corrected to match.

Widening cannot make Android worse. If the socket or the group bind is refused
— SELinux policy for an untrusted app is the obvious case — `open_link_socket`
returns an error, `with_groups` leaves `inner` as `None`, and
`build_wake_source` falls to the timer. That is exactly today's behaviour, with
an honest log instead of a wrong one.

This lands unexercised either way. The `android-check` CI leg is a `cargo
check`, so it compiles the code and cannot see a socket that opens and stays
silent, and nothing in CI runs on a device. Verified as far as that allows: the
widened arm needs `AF_NETLINK`, `SOCK_RAW`, `SOCK_NONBLOCK`, `SOCK_CLOEXEC`,
`NETLINK_ROUTE` and `sockaddr_nl`, and all six are defined for Android in the
pinned libc 0.2.189, the last three in the android module itself. Not run on a
device.

Two Android items are deliberately not here, both tracked separately: bionic's
`getifaddrs` restriction for apps targeting API 30+, which likely degrades the
fingerprint to the source-address probe alone, and a `ConnectivityManager`
push, which is the durable signal under Doze where a timer is not reliable.
The reaction to a medium change ran on the rx loop and awaited a heartbeat to
every peer in turn. For a peer on TCP, Tor, Nym or BLE that reaches
`writer.lock().await` and then a bare `write_all(data).await`. The connect
above it is wrapped in a timeout; the write and the lock are not. A medium
change is precisely the condition that leaves a send window full against a
path that has just gone away, so that write could hold the rx loop — every
other arm of the select with it — for as long as the stranded socket took to
fail.

The fan-out now covers connectionless transports only. That is not an
optimisation, it is the whole point: a UDP send completes without awaiting the
wire, because the fast path hands the frame to the encrypt workers and
returns, and a raw datagram write does not wait for a peer.

Neither obvious alternative works. A timeout around the send is unsafe, not
merely insufficient: dropping a partial `write_all` leaves a half-written
frame on the stream, which the peer cannot resynchronise from. And the loop
cannot simply be spawned, because the send needs `&mut self` for the session
counter and the MMP sender record, and neither `Node` nor `TransportHandle` is
shared or cloneable.

A connection-oriented peer is not stranded by the omission. It keeps the
periodic heartbeat it had before this detector existed, its transport re-dials
on send, and `link_dead_timeout_secs` remains the backstop — so this is the
behaviour that shipped in v0.5.0 for those peers, unchanged, rather than a
regression introduced here. Doing better for them means dropping the stale
connection rather than writing into it, which is a different change with a
real cost behind it: a Tor peer would pay a fresh circuit every time the
fingerprint moved, and the fingerprint currently moves for things like a
docker bridge appearing.

The handler's `info!` now reports how many heartbeats actually went out,
rather than leaving it inferred from the peer count.

Covered by a regression test that re-pins an established peer onto a TCP
transport and asserts its heartbeat timestamp does not move across a change.
Verified by mutation: with the filter replaced by `true`, the test fails.
@fr34aky

fr34aky commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

All four actions are in, and the two Android follow-ups are filed as #145 and
#146. Details at the end. Your question first, since it is the interesting one.

What the address set is for

Short answer: less than the module docs claim, and one of the two jobs I wrote
down for it does not exist.

The docs at src/node/netmon/mod.rs:58-62 give it two: catching a medium
arriving or leaving without displacing the default route, and "the address a
link-local Ethernet transport peers over". The second is wrong. The Ethernet
transport is pure L2 — AF_PACKET/SOCK_DGRAM on Linux, BPF on macOS, peers
addressed by MAC (src/transport/ethernet/mod.rs:430, parse_mac_addr at
:618). It has no IP address, and an Ethernet peering does not require the
interface to have one. Its interface's addresses appearing or vanishing is not a
signal about that peering at all. I wrote that line; it does not survive
checking.

What genuinely remains is one case, and it is real: a peer whose path is not
the default route.
A UDP peer on the same LAN is reached by the on-link subnet
route. preferred_source() probes an off-link destination
(PROBE_V4/PROBE_V6, :91-98), so it follows the default route by
construction and cannot see that peer's path at all. Concretely: a laptop on
cellular for internet with a LAN cable to a local peer. Unplug the cable and
v4_source does not move — the default route was never on that interface — but
every connected socket to that peer is stranded. The address set catches it,
because the LAN address leaves the set.

So the address set is a proxy for "could the local end of one of our
peerings have moved", covering the paths the off-link probe structurally
misses. That is the whole job.

Why I would not narrow it the way you suggested

Dropping fe80::/10 and 169.254/16 removes the veth half of your noise, but I
think it removes signal with it, and keeps the part that actually caused the
problem. Link-local is exactly the scope an on-link peering can use, so those
addresses are among the few in the set that are doing the job above. The Docker
bridge at 10.128.1.1/24 is global scope and would stay — and that is the
address whose appearance moved your fingerprint.

The filter is not narrow in the wrong dimension. It is enumerating the wrong
thing
: every address the host happens to have, when what matters is the local
end of the peerings the node actually has.

What I think the right shape is

Probe per peer instead of enumerating interfaces: for each peer with an IP
transport address, connect(2) a UDP socket to it and read the local address —
the same two-syscall, no-packets operation preferred_source already does, just
aimed at the peers rather than at a documentation prefix. The fingerprint
becomes the set of local addresses the kernel would use to reach the peers we
have.

That is strictly better on every axis you raised:

  • Docker, Tailscale, veth and tunnels disappear by construction, not by a
    filter that has to keep guessing which interface names are infrastructure. No
    peer is reached through them, so they never enter the set. docker compose up
    moves nothing.
  • It subsumes both current signals. An off-link peer's probe follows the
    default route, which is what preferred_source measures today. An on-link
    peer's probe follows its subnet route, which is the case the address set was
    covering.
  • It answers your last follow-up too — the host-global fingerprint that
    cannot see a per-peer route change. A more specific route moving one peer
    changes that peer's probe result and nothing else, which is precisely the
    granularity the symptom has.

Costs and caveats, so this is not oversold. It is two syscalls per peer per
sample rather than two per sample, bounded by max_peers and still no packets
and no I/O wait. Peers on a non-IP or proxied transport (Ethernet by MAC, BLE,
Tor and Nym through a local SOCKS proxy) have no address worth probing and
contribute nothing — correct, since none of them is IP-attached in the way this
detector reasons about. A node with no peers gets an empty fingerprint and
detects nothing, which is right: there is nothing to rebind, and
a_change_with_no_peers_is_harmless already pins that. A peer configured by
hostname needs its resolved address rather than a fresh lookup on the sample
path, which is a detail but a real one.

I have not built this. Happy to do it here or as a follow-up — it is a
self-contained change to Fingerprint::sample, and the reaction, the debounce
and the settled-back suppression all stay as they are. Your call on whether it
holds up the merge; I would lean to a follow-up, since the current fingerprint
is noisy on a container host rather than wrong.

One note on the cost you measured, which is now slightly lower: b1a1043 stops
the fan-out heartbeating peers on connection-oriented transports, so a spurious
change no longer touches TCP or Tor peers at all. It still drops connected UDP
sockets and still logs info! saying the medium changed when it did not. Both
of those go away under the per-peer shape.

The four actions

  • Android cfg wideningf769414, its own commit, the five predicates
    and nothing else. I confirmed the libc chain rather than taking it on trust:
    PF_ROUTE = AF_ROUTE = AF_NETLINK (16) and AF_UNSPEC = 0 = NETLINK_ROUTE in
    the pinned 0.2.189, and all six symbols the widened arm needs are defined for
    Android. Three platform tables claimed Android polls, not one — the module
    table, the watcher.rs header, and docs/reference/configuration.md — plus a
    CHANGELOG line saying Android would get an embedder push "where SELinux blocks
    netlink". All corrected. Not run on a device, and I have no NDK, so the
    android-check leg is the only compile evidence.
  • Retargeted to master, rebased with dfc1dd20 first. Two conflicts, both
    from master moving after your review: the CHANGELOG against your packaging
    entry, and ci.yml, where 25daa4d relocated the deb-install steps so my
    branch carried a stale copy of them. check-ci-parity.sh still reports 13
    suites and 23 legs.
  • Commits reordered so src/node/netmon/linux.rs never appears. Checked per
    commit tree, not just in the diff.
  • The fan-outb1a1043, but not by spawning, because that does not
    compile. The send needs &mut self for the session counter and the MMP sender
    record, and neither Node nor TransportHandle is shared or cloneable. A
    timeout is worse than insufficient rather than a fallback: dropping a partial
    write_all leaves a half-written frame the peer cannot resynchronise from. So
    it skips the peers whose send can block instead, keyed on
    TransportType::connection_oriented, which makes the unbounded write
    unreachable from this path rather than merely bounded. A peer on TCP, Tor, Nym
    or BLE keeps the periodic heartbeat it had before this detector existed, so
    that is v0.5.0 behaviour unchanged rather than a regression added here.
    Regression test re-pins an established peer onto a TCP transport and asserts
    its heartbeat timestamp does not move; mutation-checked by replacing the
    filter with true, which fails it.

I did not extend the fan-out to dropping those peers' stale connections, though
that is the honest analogue of dropping a stranded UDP socket. The cost is not
symmetric — a Tor peer pays a fresh circuit every time the fingerprint moves,
and per the above the fingerprint currently moves for a Docker bridge. That
seemed worth settling together with the filter question.

cargo fmt --check, both clippy passes, cargo test (2425 passed, 0 failed),
and ./testing/ci-local.sh --only medium-change (17/17) all pass on
b1a1043.

…is for

One of the two jobs the module docs gave it does not exist. "The address a
link-local Ethernet transport peers over" is wrong: that transport is pure L2 —
`AF_PACKET`/`SOCK_DGRAM` on Linux, BPF on macOS, peers addressed by MAC — and an
Ethernet peering neither has nor needs an IP address on the interface. Its
addresses appearing or vanishing say nothing about that peering.

What remains is one case, and it is the real one. A peer on the same LAN is
reached by the on-link subnet route, while `preferred_source` probes an off-link
destination and so follows the *default* route by construction. It looks
straight past that path. Unplug a LAN cable on a host whose default route is
cellular: the source addresses do not move, and every connected socket to that
peer is stranded.

Also names the set for what it is — a proxy for "could the local end of one of
our peerings have moved" — and says plainly that it is a broad one, since it
enumerates every address the host has rather than the local end of the peerings
the node holds. A container bridge or a tunnel interface appearing moves it too.
`test.sh` renders the node configs from the topology before compose starts and
leaves them in place for post-mortem, under a directory named with the run
suffix — so every run leaves another one behind in the working tree. Same
reason `/testing/docker-*/` is already ignored.
@fr34aky

fr34aky commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

The doc line I flagged above as wrong is fixed in 2bdeac8 — the Ethernet claim is gone and the interface-address half now says what it actually does. cd43877 adds a .gitignore rule for the medium-change suite's per-run config directories. Both are docs/ignore only, so the gate results in the previous comment still stand; re-run on the new head is green so far with nothing failed.

@fr34aky
fr34aky marked this pull request as ready for review September 6, 2026 09:15
@jmcorgan

jmcorgan commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Landed on master as fc5b4ade. GitHub shows this as closed rather than merged because the commits were rewritten on the way in.

I want to be straight about the basis for merging it. local_addrs is every up, non-loopback address on the host, so a docker bridge coming up, a VPN connecting, or a container network appearing all move the fingerprint with no peering affected at all, and the reaction to a moved fingerprint is to drop every connected socket and heartbeat every peer. It is self-healing, so it costs work rather than connectivity, but on any host running containers it can fire repeatedly for nothing. That is a real cost we are taking on by merging, and it needs to be closed rather than lived with.

Your per-peer probe removes the whole category, since a virtual interface appearing does not change the source address the kernel picks for any actual peer. It also closes the on-link case you found yourself. So I am treating the probe as owed rather than optional, and this lands on the understanding that it follows. Detail at the end.

Three things changed as it went in. I squashed the nine commits to two, one per author, so Arjen's watcher stays his and yours is one commit; that consumes his feat/link-watcher entirely and it needs no pull request of its own. I made transport::watcher pub(crate), since every caller is in-crate and publishing it would commit the library to LinkWatcher's shape before anything outside has asked for it. And your changelog entries moved to Unreleased, because the section you wrote them into shipped as 0.5.1 while this was open.

One thing to flag: node.netmon.* and src/node/netmon are a new capability, and the maintenance line takes bug fixes and tooling only. So this goes out in the next minor release and is not in v0.5.0 or v0.5.1.

Six smaller follow-ups, none of which held the merge:

  1. The unbounded stream write is still reachable from the tick's own heartbeat sweep about ten seconds later, same peer, same state (src/node/handlers/netmon.rs:137-148, src/node/handlers/mmp.rs:433-530). Taking it out of the handler did not take it out of the path behind the handler.
  2. A failed heartbeat still resets the heartbeat timer.
  3. node.netmon.* is unvalidated (src/config/node.rs:215-278).
  4. Nothing in CI can tell a right netlink group mask from a wrong one. a_route_change_alone_reaches_the_watcher is the only test that discriminates it and it needs CAP_NET_ADMIN, so a regression to link-events-only would pass everything.
  5. Two doc claims do not match the code: docs/reference/configuration.md:218-223 says connection-oriented transports "re-dial on send", and src/node/netmon/mod.rs:30-32 names an "interface binder" that does not exist here.
  6. The heartbeated count reports targets selected, not sends that succeeded (src/node/handlers/netmon.rs:151-164).

On the probe itself: the premise holds and it does subsume both current signals, so I am not asking you to narrow the shape, only to build it. Two conditions when you do. Compare the intersection of consecutive samples over a per-peer map rather than a set, so peer churn cannot fire the fan-out on its own. And reach the peer table through entities_snapshot (src/node/mod.rs:531) rather than adding a sharing primitive, since the detector is deliberately a detached task with no node state.

That also settles #145: the probe deletes interface_addrs()'s only call site, so it absorbs that issue rather than leaving it to be fixed separately. Do not spend anything on it. I will answer both issues on their own threads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-peer connected UDP socket keeps its connect()-time source address across a local medium change

3 participants