Skip to content

Do not announce IPv6 unicast locators over an IPv4-only transport - #441

Merged
jhelovuo merged 1 commit into
Atostek:masterfrom
alpitol:fix/no-ipv6-unicast-locators
Sep 11, 2026
Merged

Do not announce IPv6 unicast locators over an IPv4-only transport#441
jhelovuo merged 1 commit into
Atostek:masterfrom
alpitol:fix/no-ipv6-unicast-locators

Conversation

@alpitol

@alpitol alpitol commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

This is ai-generated pull request (Copilot & Claude Opus 5) to fix warning spam I had. So check this PR with care. I do not understand how every linux pc is not affected by it? So please test it on your machine if it is reproducible, because it's hard to believe issue this direct could be present on every linux machine. The ai's description below:

Do not announce IPv6 unicast locators over an IPv4-only transport

Summary

A RustDDS participant announces a unicast locator for every local interface
address, IPv6 included, but the unicast transport is IPv4-only. Peers — including
the participant itself, which discovers itself over loopback multicast — then send
RTPS metatraffic to those IPv6 locators through an IPv4 socket. Every such datagram
fails with EAFNOSUPPORT and logs a WARN, thousands of times per second.

The bug

src/network/util.rs, get_local_unicast_locators_inner:

ifaces
  .iter()
  .filter(|ifa| only_networks.is_none_or(|nets| nets.contains(&ifa.ip)))
  .map(|ifa| Locator::from(SocketAddr::new(ifa.ip, port)))
  .collect()

Every address is turned into a locator. But the sockets underneath are IPv4-only:

  • src/network/udp_listener.rs, new_listening_socket:
    Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))? — the unicast
    listener binds 0.0.0.0, so we never receive on an IPv6 address.
  • src/network/udp_sender.rs, new_with_networks: the unicast_socket is
    likewise Domain::IPV4, bound to 0.0.0.0, so we can never send to one either.

The multicast path already encodes this restriction — get_local_multicast_ip_addrs_inner
ends with .filter(IpAddr::is_ipv4) — so the unicast path is simply inconsistent
with the rest of the module.

Consequently the announced IPv6 locators are addresses we can neither receive on
nor send to. Discovery hands them to RtpsReaderProxy/RtpsWriterProxy, the
transmit path calls UDPSender::send_to_locator, which routes a non-multicast
destination to SocketId::Unicast, and raw_send produces:

WARN rustdds::network::udp_sender] raw_send: Unicast to [fe80::d494:8fff:fe08:3ce3]:7420
  : Os { code: 97, kind: Uncategorized, message: "Address family not supported by protocol" } len=64

There is one such line per datagram, per unreachable locator.

Why this is a bug and not cosmetic

  1. It is unconditional and self-inflicted. No IPv6 configuration, no remote
    peer and no unusual setup is required. A single process creating two
    participants on a stock Linux box reproduces it, because a participant
    discovers itself over loopback multicast and reads back its own locator list.
    Any interface with a link-local address — which is every real interface on a
    modern Linux host — contributes one unreachable locator.

  2. The log volume makes RustDDS applications unusable at WARN. On my host
    (7 IPv6-capable interfaces: wlan, two docker bridges, two veths, two USB
    ethernets) the MWE below emits 14 181 warnings in 10 seconds. WARN is
    the level applications leave on in production; this drowns everything else.
    The application cannot fix it — there is no public API to exclude the IPv6
    addresses, since only_networks is an allow-list of addresses that must
    still resolve through the same code path.

  3. We advertise unreachable addresses to other implementations. SPDP/SEDP
    locator lists are consumed by other vendors too. Announcing addresses we
    cannot serve is wrong on the wire, wastes peers' send attempts, and can slow
    endpoint matching for anyone who tries the locators in order.

  4. Link-local IPv6 could never work here anyway. Locator carries no IPv6
    scope id, so fe80::… is unusable even for an IPv6-capable socket — the
    kernel cannot know which interface to use. These locators are not "IPv6
    support waiting to be enabled"; they are not addressable at all.

  5. The warning is misattributed. EAFNOSUPPORT here is not a transient
    network fault worth warning about; it is the guaranteed outcome of asking an
    IPv4 socket for an IPv6 destination. It also costs a failing syscall per
    datagram.

MWE

Save the script below as examples/ipv6_locator_mwe/main.rs and run:

RUST_LOG=warn,rustdds::dds::statusevents=off cargo run --example ipv6_locator_mwe

(No special networking setup is needed — any host with at least one
IPv6-capable interface (i.e. essentially any Linux/macOS machine, since every
real NIC gets a link-local address) reproduces it. The statusevents=off filter suppresses an unrelated, pre-existing warning StatusChannelSender cannot send new status changes, channel is full.)

//! MWE for "raw_send: Unicast to [fe80::...] : EAFNOSUPPORT" warning spam.
//!
//! Two DomainParticipants in one process, on one host. They discover each
//! other, each announces a unicast locator for *every* local interface address
//! (including IPv6), and then each tries to send RTPS metatraffic to those
//! IPv6 locators over an IPv4-only socket.
//!
//! Run with:
//!
//!   RUST_LOG=warn,rustdds::dds::statusevents=off cargo run --example ipv6_locator_mwe
//!
//! The `statusevents=off` part silences an unrelated, pre-existing warning
//! ("StatusChannelSender ... channel is full") emitted by RustDDS's own builtin
//! discovery endpoints. It appears identically with and without this fix, and
//! an application cannot drain those channels, so it is filtered out here to
//! keep the output about one thing only.
//!
//! Expected (correct) output: no warnings.
//! Actual output on any host with an IPv6-capable interface: a warning per
//! datagram, thousands per second.

use std::{thread, time::Duration};

use rustdds::{policy::Reliability, DomainParticipantBuilder, QosPolicyBuilder, TopicKind};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Debug)]
struct Msg {
  value: i32,
}

fn main() {
  env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();

  let qos = QosPolicyBuilder::new()
    .reliability(Reliability::Reliable {
      max_blocking_time: rustdds::Duration::from_millis(100),
    })
    .build();

  // Reliable traffic makes the effect obvious: the ACKNACK/HEARTBEAT exchange
  // is retried, so the failing sends repeat. Discovery alone already triggers
  // it, just less often.
  let dp_writer_side = DomainParticipantBuilder::new(0).build().unwrap();
  let dp_reader_side = DomainParticipantBuilder::new(0).build().unwrap();

  let topic_w = dp_writer_side
    .create_topic(
      "Ipv6LocatorMwe".to_string(),
      "Ipv6LocatorMwe::Msg".to_string(),
      &qos,
      TopicKind::NoKey,
    )
    .unwrap();
  let topic_r = dp_reader_side
    .create_topic(
      "Ipv6LocatorMwe".to_string(),
      "Ipv6LocatorMwe::Msg".to_string(),
      &qos,
      TopicKind::NoKey,
    )
    .unwrap();

  let writer = dp_writer_side
    .create_publisher(&qos)
    .unwrap()
    .create_datawriter_no_key_cdr::<Msg>(&topic_w, None)
    .unwrap();
  let _reader = dp_reader_side
    .create_subscriber(&qos)
    .unwrap()
    .create_datareader_no_key_cdr::<Msg>(&topic_r, None)
    .unwrap();

  println!("running for 10 s; watch the log");
  for value in 0..100 {
    writer.write(Msg { value }, None).unwrap();
    thread::sleep(Duration::from_millis(100));
  }
}

Result on master (0.14.2), 10 s run:

$ RUST_LOG=warn,rustdds::dds::statusevents=off  cargo run --example ipv6_locator_mwe
WARN rustdds::network::udp_sender] raw_send: Unicast to [fe80::c886:69ff:fe1c:5ad4]:7420 : Os { code: 97, ... } len=64
WARN rustdds::network::udp_sender] raw_send: Unicast to [fe80::f222:6977:6f76:542f]:7420 : Os { code: 97, ... } len=64
WARN rustdds::network::udp_sender] raw_send: Unicast to [fe80::7d14:979e:9951:b733]:7420 : Os { code: 97, ... } len=64
WARN rustdds::network::udp_sender] raw_send: Unicast to [fe80::5d08:5ab9:9954:4690]:7420 : Os { code: 97, ... } len=64
WARN rustdds::network::udp_sender] raw_send: Unicast to [fe80::d494:8fff:fe08:3ce3]:7420 : Os { code: 97, ... } len=64
...

With this PR these warning dissappear.

The fix

Two independent changes, both small:

  1. src/network/util.rs — do not announce what we cannot serve.
    get_local_unicast_locators_inner now filters to IPv4, matching the
    IPv4-only unicast listener/sender and mirroring what
    get_local_multicast_ip_addrs_inner already does. This removes the cause.

  2. src/network/udp_sender.rs — do not warn about a destination we structurally
    cannot reach.
    New socket_can_reach checks the chosen socket's address
    family against the destination; raw_send drops a mismatched datagram with a
    trace! instead of attempting a syscall that can only fail. This covers the
    remaining legitimate case: a remote peer announcing IPv6 locators, which is
    not a local fault and must not cost a WARN per datagram. SocketId::Unicast
    is IPv4-only; a multicast socket is checked against the family of the
    interface address it was bound to, so the existing IPv6 multicast branch in
    new_with_networks keeps working if multicast enumeration is ever widened.

Behaviour is otherwise unchanged: IPv4 locators, multicast enumeration and the
only_networks filter all behave exactly as before.

Tests

Two new unit tests:

  • network::util::tests::unicast_locators_exclude_ipv6 — a synthetic interface
    list containing a link-local and a ULA IPv6 address yields only the IPv4
    locator. Uses the existing injectable _inner helper, so it is deterministic
    and independent of the host's interfaces.
  • network::udp_sender::tests::unicast_socket_rejects_ipv6_destination — the
    unicast socket reports it cannot reach an IPv6 destination, and raw_send
    returns SendOutcome::Dropped for it.

Full suite passes: cargo test → 692 + 2 + 58 tests, 0 failures.
cargo +nightly fmt and cargo +nightly clippy --tests --examples are clean.

Notes for reviewers

  • Should IPv6 unicast be supported properly one day, the right shape is an
    IPv6 (or dual-stack) unicast listener + sender plus scope-id-carrying
    locators. The socket_can_reach check added here is the correct guard in
    that world too — it is not a workaround that would need removing.
  • I originally hit this through ros2-client 0.10.1: a single ROS 2 node with
    no peers and no traffic produced 6 749 of these warnings in 15 seconds.
  • Unrelated, but found while building the MWE: two bare DomainParticipants
    with no user endpoints emit ~250 StatusChannelSender ... channel is full
    warnings in 10 s, from the DataWriterStatus/DataReaderStatus channels of
    the builtin discovery endpoints (sync_status_channel(4) in
    src/dds/pubsub.rs). Applications have no way to drain those. Happy to open a
    separate issue if that is useful — it is not touched here.

`get_local_unicast_locators_inner` turned every local interface address
into an announced unicast locator, IPv6 included. But our unicast
transport is IPv4-only: `UDPListener::new_listening_socket` and the
`UDPSender` unicast socket are both created with `Domain::IPV4`. (The
multicast path already reflects this -- `get_local_multicast_ip_addrs_inner`
filters on `IpAddr::is_ipv4`.)

So every announced IPv6 locator is an address we cannot receive on and
cannot send to. Peers -- including our own participant, which discovers
itself over loopback multicast -- dutifully send RTPS metatraffic there,
and each datagram fails with EAFNOSUPPORT and logs a WARN:

  raw_send: Unicast to [fe80::d494:8fff:fe08:3ce3]:7420 :
    Os { code: 97, ... "Address family not supported by protocol" } len=64

On a host with several IPv6-capable interfaces (docker0, br-*, veth*,
wlan, ...) this is thousands of warnings per second, which drowns the
log of any application using RustDDS. Link-local addresses are doubly
useless here, since a `Locator` carries no IPv6 scope id, so even an
IPv6-capable socket could not use them.

Two changes:

* `get_local_unicast_locators_inner` now filters to IPv4, so we only
  advertise addresses the transport can actually serve. This also stops
  us handing unreachable locators to other vendors' implementations.

* `UDPSender::raw_send` checks that the chosen socket's address family
  matches the destination before calling `send_to`, and drops the
  datagram with a `trace!` otherwise. A remote peer may legitimately
  announce locators we cannot reach; that is not a warning-worthy local
  fault, and it must not cost a syscall + WARN per datagram.

Behaviour is otherwise unchanged: IPv4 locators, multicast enumeration
and the `only_networks` filter all work exactly as before.

Co-authored-by: Copilot & Claude Opus 5
@jhelovuo
jhelovuo merged commit 296c97d into Atostek:master Sep 11, 2026
6 of 7 checks passed
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.

2 participants