Repository navigation
Description
#1764 made parsed_scoped_addresses() prefer a scoped copy of an AAAA record when an unscoped one is also cached, and left "the IPv4-only-arrival case" for a follow-up. I couldn't find an issue for that follow-up, so here it is with measurements. In short: on a dual-stack host the scoped copy usually never reaches the cache, so #1764 has nothing to prefer. Disabling packet duplicate suppression is enough for 0.151.5 to return fe80::…%<idx>.
Setup
- Querier: Linux 6.12, 32-bit ARM (armv7l), Python 3.14. One wired interface (index 2) with IPv4, a link-local address, and global and ULA IPv6 addresses (SLAAC, with temporary addresses).
AsyncZeroconf(ip_version=IPVersion.All), default interfaces. - Peers: embedded devices on the same link. Each answers with its A record and three AAAA records (link-local, global, ULA) in one message, over both IPv4 and IPv6.
- Version: 0.151.5, and master at f0c27b3 (2026-10-01), whose
src/zeroconfis identical to 0.151.5. Both were run as pure Python soAsyncListener._process_datagram_at_timecould be wrapped. For every response carrying a peer's link-local AAAA, the wrapper logged which socket it arrived on, the sender's address, and the scope zeroconf would give it. It logged before the duplicate check runs. - Control: a plain IPv6 UDP socket on 5353, joined to ff02::fb on interface 2, sent an AAAA query. All 18 peers answered by multicast from their
fe80::address, andrecvfromgave scope 2 for every one. The peers do their part, and the kernel supplies the scope.
Results
One-shot lookups: AddressResolver(host).async_request(zc, 3000), a fresh instance each time, 10 rounds over 3 peers (0.151.5).
| Measurement | 0.151.5 |
|---|---|
Lookups where parsed_scoped_addresses() gave a usable link-local scope |
0 / 30 |
| IPv6 respond socket bound to a global address, not link-local | 30 / 30 |
First copy of the link-local AAAA in an IPv4 packet (scope None) |
14 |
| First copy by unicast IPv6 from the peer's global address (scope 0) | 16 |
A scoped copy (from the peer's fe80::, scope 2) received at all |
1 |
| ...that ended up in the cache | 0 |
In that one lookup, the IPv4 copy arrived on the :: listen socket from v4-mapped ::ffff:192.0.2.x (scope 0) at 571 ms. The IPv6 copy from the peer's fe80:: address (scope 2) followed at 573 ms. The cache ended with only the scope-0 and None entries.
Browsing: an AsyncServiceBrowser for the peers' service type, with the cache read every 10 s.
- Unmodified (0.151.5, 300 s): the link-local addresses stayed unscoped throughout.
- 0.151.5 with
_DUPLICATE_PACKET_SUPPRESSION_INTERVAL = 0, otherwise unchanged: the same pair (v4-mapped first,fe80::4 ms later) arrived at 202 s for all three peers. From then on the cache held a scope-2 entry for each, andparsed_scoped_addresses()returnedfe80::…%2for all three.
Duplicate drops on master: a 300 s browse with debug logging logged 43 "Ignoring duplicate message" lines for packets from the peers' fe80:: addresses. Those are the only copies that carry a scope. It also logged 183 for v4-mapped senders. All were on the :: socket.
Why: four ways the scope is lost
- Duplicate suppression ignores the sender. The IPv6 listen socket is bound to
::withIPV6_V6ONLYoff, so it receives both IPv4 and IPv6 multicast. A peer's IPv4 and IPv6 announcements are byte-identical, and the IPv4 copy usually arrives first. The listener's duplicate checks compare payload bytes only: both the last-packet check and the_recent_packetscache from fix: bound duplicate-packet dedup against alternating-payload floods #1750. So the IPv6 copy, the only one that carries a scope, is dropped. fix: bound duplicate-packet dedup against alternating-payload floods #1750 keeps that state per listener "so a duplicate seen on one interface does not suppress" a packet arriving on another. But with the dual-stack::socket, the IPv4 and IPv6 sides of one interface share a listener. fix: preserve scope_id when scoped AAAA arrives alongside unscoped #1764 can't help, because the scoped record never reaches the cache. - The IPv4 copy is scoped from its sender. On the
::socket it arrives as::ffff:a.b.c.dwith scope 0. That gives a cache entry with scope 0, which is no more usable than one with none. - The first answer comes over IPv4, unicast. The first query is QU, so the peer answers by unicast to the query's source address, and an IPv4 answer carries the AAAA records too. It arrives on the IPv4 respond socket, so it has no scope, although that socket belongs to one interface.
- The IPv6 respond socket is bound to a global address. zeroconf binds one IPv6 respond socket per interface, to one of the interface's addresses. Here it was always a global one (which one varied between runs), never the link-local. So a QU answer over IPv6 comes from the peer's global address, and the scope taken from that sender is 0.
The RFCs
- RFC 6762 §6.2: a responder "MUST include all addresses that are valid on the interface on which it is sending the message". The RFC's example is a link-local plus a routable address, "so that queriers receive both and can make their own choice", and it says this "allows a querier that only has an IPv6 link-local address to connect to the link-local address". The same section says a responder SHOULD include the other address type in the additional section, so that "all a device's addresses are delivered atomically in a single message". An AAAA record arriving in an IPv4 packet (cases 1 to 3) is therefore normal, conformant behaviour, not an edge case.
- RFC 6762 §14: addresses in responses "MUST be valid for use on the interface on which the response is being sent", and a host needs "network programming APIs that allow applications ... to discover on what interface a Multicast DNS response was received". So a link-local address in a response belongs to the interface the response arrived on. For the respond sockets that interface is known, since each is bound to one. The shared
::listen socket serves every interface, so there it isn't known. Either way, the sender's sockaddr only gives the interface when the sender happens to be link-local. - RFC 6762 §5.4: the first questions are QU, and "a responder SHOULD usually respond with a unicast packet directed back to the querier". That is why the first answer arrives unicast, on whichever family and source address the query went out from (cases 3 and 4).
- RFC 6762 §20: a dual-stack host "acts like a multihomed host, with one connection to the logical 'IPv4 Ethernet segment', and a connection to the logical 'IPv6 Ethernet segment'". On that view an IPv4 and an IPv6 copy of the same answer are two separate arrivals, not a duplicate packet (case 1).
- RFC 4007 §6 and §11.2: a non-global address needs a zone index, and zone 0 or no zone means the default zone. Linux has no default zone for link-local addresses, so connecting to
fe80::…orfe80::…%0fails withEINVAL. A cache entry with scope 0 is as unusable as one with none.
Possible fixes
Roughly from smallest to largest:
- Key duplicate suppression on the scope the packet's records would get (
addrs[3], 0 otherwise), so the scoped IPv6 copy of an answer isn't dropped behind the unscoped IPv4 one (case 1). This looks like the change that would matter most in practice. The control above shows it's enough for fix: preserve scope_id when scoped AAAA arrives alongside unscoped #1764 to work whenever a peer multicasts over IPv6. PR fix: don't suppress a scoped copy of a packet as a duplicate of an unscoped one #1926 does this. - Don't scope records from a v4-mapped sender (case 2). Those packets are IPv4, and scope 0 means "unknown".
- Bind the per-interface IPv6 respond socket to the interface's link-local address (case 4). QU answers over IPv6 then come from the peer's link-local address, with the right scope.
- Scope link-local AAAA records from the receiving interface, IPv4 packets included (case 3), as RFC 6762 §14 describes. The draft WIP: Use per-interface per-protocol sockets #1568 ("Use per-interface per-protocol sockets") does this: one socket per interface and per address family, each listener stamping its interface index on what it receives, and IPv6 bound to link-local. That would also cover cases 1, 2 and 4. The alternative Link-local addresses returned without scope. #1567 mentions is
IP_PKTINFO/IPV6_RECVPKTINFOwithrecvmsg.
Our workaround in the meantime is to derive the scope ourselves: the interface whose IPv4 subnet or IPv6 prefix contains one of the peer's other addresses.
We'd be glad to help further, including running any patched build (#1568 rebased, for instance) against the same peers with the same measurements.
🤖 Generated with Claude Code
Activity