Repository navigation
fix: don't suppress a scoped copy of a packet as a duplicate of an unscoped one - #1926
Conversation
…scoped one A dual-stack listen socket receives a responder's IPv4 copy of a message, from a v4-mapped sender with scope 0, and its byte-identical IPv6 copy, from a link-local sender with the interface's scope. Suppressing on the payload alone dropped the IPv6 copy, the only one whose link-local AAAA records get a scope, so they were never cached scoped. RFC 6762 section 20 treats a dual-stack host as on two logical segments, so the copies are separate arrivals. Key suppression on the scope the records would get as well: unscoped senders suppress each other as before, and a scoped sender's copy is keyed on its payload and scope.
|
Thanks for contributing! A quick note: this project is in the process of moving from LGPL-2.1 to Apache 2.0, and may keep LGPL-2.1-or-later as an OR option so GPL-2.0-only projects can keep using the library, see #1835. By continuing with this pull request you agree that this new contribution is offered under both Apache 2.0 and LGPL-2.1-or-later, so it is covered under either outcome. This notice does not affect your previous contributions. If you would like to consent to relicensing those as well, please post the consent statement on #1835; silence is never treated as consent for past work. If offering this contribution under these licenses does not work for you, no hard feelings; reply with the exact phrase |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1926 +/- ##
=======================================
Coverage 99.87% 99.87%
=======================================
Files 33 33
Lines 3913 3941 +28
Branches 568 573 +5
=======================================
+ Hits 3908 3936 +28
Misses 3 3
Partials 2 2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
🟡 Changes recommended
Scope-sensitive deduplication can duplicate known answers and multicast responses, and emit spurious service update events for global IPv6 addresses.
3 open issues
What changed in this PR
This PR makes packet duplicate suppression scope-aware so an unscoped IPv4 copy no longer blocks the scoped IPv6 copy needed for usable link-local addresses. It complements the earlier ServiceInfo scope-preference fix.
Changes:
- Include sender scope in both duplicate-suppression paths while preserving the bounded recency cache and QU handling.
- Update Cython declarations for the new scope-tracking state.
- Add a regression test verifying that scoped and unscoped responses are both cached and their repeats remain suppressed.
| File | Description |
|---|---|
tests/test_listener.py |
Tests scope-aware response handling and repeat suppression. |
src/zeroconf/_listener.py |
Distinguishes packet copies by sender scope. |
src/zeroconf/_listener.pxd |
Declares scope-tracking fields and locals for Cython. |
Review effort: Balanced
| key = (data, sender_scope) if sender_scope else data | ||
| recent_time = self._recent_packets.get(key, -1e30) |
There was a problem hiding this comment.
Fixed in a334cb0. _add_question_with_known_answers collapses AAAA known answers by address before writing them, so a record cached under several scopes goes on the wire once. The question history still sees the full set. Covered by test_aaaa_known_answer_sent_once_per_address.
| key = (data, sender_scope) if sender_scope else data | ||
| recent_time = self._recent_packets.get(key, -1e30) |
There was a problem hiding this comment.
Fixed in a334cb0. DNSIncoming now attaches the receiving scope only to link-local (fe80::/10) AAAA records, since a zone index has no meaning elsewhere (RFC 4007 section 6). A global AAAA from a link-local sender is the same record as its unscoped copy, so no new cache entry and no extra Updated. Covered by test_aaaa_scope_only_applied_to_link_local.
|
|
halibsbot
left a comment
There was a problem hiding this comment.
Warning
Important issues found.
- Scope-aware dedup also lets dual-stack queries through, so one query is answered twice
Scope-aware suppression now applies only to responses, so the IPv4 and IPv6 copies of a query are still answered once (RFC 6762 sections 6, 14). The parser attaches the receiving scope only to link-local AAAA records (RFC 4007 section 6), so global addresses from differently scoped senders no longer appear as new records and fire extra Updated events. AAAA known answers cached under several scopes are written once.
Merging this PR will not alter performance1 warning
Comparing |
|
|
halibsbot
left a comment
There was a problem hiding this comment.
Tip
No blocking issues found — ready to merge.
|
Note Automated testing results Tested 73aa8cf (a334cb0 plus a master merge) against master 7c989c9 on a live home network. Both builds were Cython-compiled and ran side by side for 120 s on macOS with
Findings
Setup
|
The scope is not on the wire, so an AAAA the packet carries under a different scope is the same record (RFC 6762 section 10.2). Without this a host with two interfaces on the link, or a dual-stack socket, expires and re-adds the other scope's variant on every announcement, firing an Updated event each time.
|
Still need some more work, lots of update event churn. |
| zc.cache.async_remove_records(cached()) | ||
| listener._process_datagram_at_time(False, len(packet), now, packet, v6_link_local) | ||
| listener._process_datagram_at_time(False, len(packet), now, packet, v4_mapped) | ||
| assert scopes() == set() |
This reverts commit 668e745.
|
We don't really have good support for multiple interfaces or separate caches per interface, so this gets messy quickly. We probably need to scope it to single record and update the scope instead |
|
|
halibsbot
left a comment
There was a problem hiding this comment.
Warning
Important issues found.
_has_scope_variantlets a goodbye keep the other scope's AAAA alive
The scope is not on the wire and there is one cache for every interface, so an AAAA received under several scopes is one record. A scoped copy replaces an unscoped one; any other copy refreshes the cached record and keeps its scope. This replaces the known-answer collapse: with one record per address the cache-flush pass, known answers and browser Updated events need no special handling.
…ress later ones DNSCache.async_get_address finds the cached AAAA for an address under any scope; the record manager takes its scope on the incoming record instead of building a new one, and stages a replaced unscoped copy with the other removes. The listener stores whether the processed copy was scoped next to its time instead of keying on (payload, scope), so a scoped copy also suppresses later copies from any sender.
|
Note Automated testing results Retested 3fab5cb against master 7c989c9 on the same network and host as the first run: both builds Cython-compiled, run side by side for 120 s with
What changed since the first run
Setup
|
|
|
halibsbot
left a comment
There was a problem hiding this comment.
Tip
No blocking issues found — ready to merge.
A goodbye now takes the cached scope like any other copy, so listeners get the RecordUpdate for it. A live scoped copy removes the unscoped one right away instead of staging it with the removes, which a full cache could have evicted first.
|
Note Automated testing results Retested bf4f5b4 (scoped goodbyes now report a
Findings
Setup
|
|
I think this is the best we can do without massively expanding scope to have a per interface cache. That's definitely outside the scope of this PR |
PR Review — fix: don't suppress a scoped copy of a packet as a duplicate of an unscoped oneMerge-ready: all earlier findings are resolved or were dismissed by a maintainer, and one non-blocking suggestion remains. The last round closed the remaining gaps. A scoped goodbye for an unscoped cached AAAA now takes the normal goodbye path and reports a I verified this by reading the code. I did not run the tests or a Cython build, because this review is read-only.
Not re-raised: first-scope-wins and a stale scope after an interface change. Dismissed per ✅ Resolved since last review (1)Previously-flagged issues verified fixed
Checklist
ℹ️ Triage summary1 pre-existing finding(s) on unchanged code suppressed (freeze). Silent Failure Analysis
🟡 **1. MEDIUM** — fallback value hides conflicting state
|
|
@akiernan I think this is ready to go, and it's the best we can do with the current design. Please retest and make sure the cleanups didn't break your use case. |
halibsbot
left a comment
There was a problem hiding this comment.
Tip
No blocking issues found — ready to merge.
|
Retested ffe6cfd on the device where I first saw this: Linux 6.12, Python 3.14, pure Python, one interface,
On Linux the IPv4 copy almost always reaches the The 4 still unscoped are python-zeroconf responders: a Home Assistant host and three of our own devices. They do answer over IPv6, but from a global address, where mDNSResponder answers from Thanks for taking this on. Written by Claude, posted on Alex's behalf. |

Summary
On a dual-stack host, a responder's IPv6 copy of a response is no longer thrown away as a duplicate of its IPv4 copy, so link-local AAAA records get their
%<interface>scope (#1567, the case #1764 left open), and the cache keeps a single AAAA per address however many scopes it is received under.parsed_scoped_addresses()returnsfe80::…%<idx>for responders whose IPv6 copy arrives after their IPv4 copy, which master never scoped.ServiceBrowserUpdatedevents need no special handling.ServiceInfo; that stays as is.Details
_listener.py): each entry in_recent_packets, and the single-slot fast path, records whether the processed copy came from a scoped sender. A copy is suppressed unless it is a scoped response and the earlier copy was not. So the scoped copy is processed once after an unscoped one, and a scoped copy suppresses every later copy from any sender. Queries are never treated as scoped (QR bit read from the raw header), so the IPv4 and IPv6 copies of a QM query are still answered once (RFC 6762 §6, §14). The key stays the payload bytes; no per-packet allocation._protocol/incoming.py): the receiving scope is attached only to link-localfe80::/10AAAA records. Global AAAA records now parse withscope_id=Nonewhere they used to carry the interface index, whichip_bytes_and_scope_to_addressalready ignored for non-link-local addresses._handlers/record_manager.py,_cache.py): on a cache miss for an AAAA, the new publicDNSCache.async_get_addressfinds the cached copy of that address under any scope. A scoped copy of an unscoped record replaces it (oneUpdated, since the address became usable). Otherwise the incoming record takes the cached scope and refreshes that record. The first scope seen is kept, so a host with two interfaces on the link does not flap between them; python-zeroconf has one cache for every interface.DNSAddressequality is unchanged.IP_PKTINFO.Measurements
Real network, macOS host with one dual-stack
::socket and two interfaces on the same link, master and this branch Cython-compiled and run side by side for 120 s browsing every service type (first run, after the cache merge, current head):Updatedevents on the shared servicesIn most runs every responder's IPv6 copy arrived first, so master scoped the same services. In one run a host's IPv6 copy arrived after its IPv4 copy: master left 22 services unscoped, this PR scoped all 50 of 50 services with a link-local address, and both found the same 194 services.
Earlier Linux run by the author (Linux 6.12, embedded responders answering over IPv4 and IPv6, on 729ada9): master dropped 43 copies from the responders'
fe80::addresses as duplicates over a 300 s browse and never scoped a link-local address; the branch hadfe80::…%2inparsed_scoped_addresses()within 10 s for every responder whose answer arrived over IPv6 multicast.Microbenchmarks, Cython build:
async_updates_from_responseTest plan
tests/test_listener.py: a scoped copy follows an unscoped one and then suppresses later copies from any sender; the scoped copy of a query is still suppressed.tests/test_protocol.py: only link-local AAAA records take the receiving scope.tests/test_handlers.py: one AAAA per address for each scope order, an unscoped copy refreshes the scoped record, a goodbye under another scope removes the cached copy and reports aRecordUpdate.tests/services/test_info.py,tests/services/test_browser.py: one AAAA known answer per address, noUpdatedfor a copy under another scope.REQUIRE_CYTHON=1) and in pure Python: 536 passed.pre-commit runon the changed files.