Skip to content

fix: don't suppress a scoped copy of a packet as a duplicate of an unscoped one - #1926

Merged
bdraco merged 10 commits into
python-zeroconf:masterfrom
akiernan:fix/dedup-per-sender-scope
Oct 31, 2026
Merged

bdraco merged 10 commits into
python-zeroconf:masterfrom
akiernan:fix/dedup-per-sender-scope

Conversation

@akiernan

@akiernan akiernan commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

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.

# before: one entry per payload, whichever copy came first wins
recent_time = self._recent_packets.get(data, -1e30)
if (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < recent_time:
    return

# after: an unscoped copy does not suppress the scoped one that follows
recent = self._recent_packets.get(data, _NOT_SEEN)
if (now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < recent[0] and (recent[1] or not scoped):
    return
  • parsed_scoped_addresses() returns fe80::…%<idx> for responders whose IPv6 copy arrives after their IPv4 copy, which master never scoped.
  • The cache keeps one AAAA per address: a scoped copy replaces an unscoped one, any other copy refreshes the cached record, and a goodbye under any scope removes it. Known answers, cache flush and ServiceBrowser Updated events need no special handling.
  • Builds on fix: preserve scope_id when scoped AAAA arrives alongside unscoped #1764, which collapses scope variants inside ServiceInfo; that stays as is.
Details
  • 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'". The IPv4 and IPv6 copies of a message are separate arrivals, and only the IPv6 copy from a link-local sender can scope its link-local AAAA records (RFC 4007 §6: a link-local address needs a zone index).
  • Duplicate suppression (_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.
  • Parser (_protocol/incoming.py): the receiving scope is attached only to link-local fe80::/10 AAAA records. Global AAAA records now parse with scope_id=None where they used to carry the interface index, which ip_bytes_and_scope_to_address already ignored for non-link-local addresses.
  • Cache (_handlers/record_manager.py, _cache.py): on a cache miss for an AAAA, the new public DNSCache.async_get_address finds the cached copy of that address under any scope. A scoped copy of an unscoped record replaces it (one Updated, 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. DNSAddress equality is unchanged.
  • Limitation: the first scope seen is kept until that record expires. If a responder moves to another interface, or the interface comes back with a new index, refreshes under the new scope keep the old one alive while the responder stays reachable. Letting a later scope win instead would make a host with two interfaces on the link flap between them.
  • Not in this PR: a responder that only ever answers over IPv4 (for example only the first, QU, query by unicast) still leaves its link-local AAAA unscoped, since an IPv4 packet carries no scope. Scoping those from the receiving interface needs per-interface sockets (WIP: Use per-interface per-protocol sockets #1568) or 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):

this PR master
IPv6 address lists identical (84 shared services) 84 / 84 —
Link-local AAAA cached under more than one scope 0 of 24 0 of 24
Updated events on the shared services 36 64
Multicast responses for the test's own service 3 3

In 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 had fe80::…%2 in parsed_scoped_addresses() within 10 s for every responder whose answer arrived over IPv6 multicast.

Microbenchmarks, Cython build:

Path Cost
Duplicate packet, single-slot or dict hit unchanged (~46 ns)
Parse of a 4-AAAA response unchanged; ~+20 ns when link-local and global AAAA are mixed
Unscoped copy of an already scoped AAAA through async_updates_from_response 812 ns vs 738 ns for an exact cache hit

Test 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 a RecordUpdate.
  • tests/services/test_info.py, tests/services/test_browser.py: one AAAA known answer per address, no Updated for a copy under another scope.
  • Full suite with the Cython build (REQUIRE_CYTHON=1) and in pure Python: 536 passed.
  • pre-commit run on the changed files.
  • Real network run above, master vs this branch.

…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.
@github-actions

Copy link
Copy Markdown

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 I do not agree to the Apache 2.0 license or simply close this pull request.

@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.87%. Comparing base (bc25ee1) to head (ffe6cfd).
⚠️ Report is 1 commits behind head on master.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread src/zeroconf/_listener.py Resolved
Comment thread src/zeroconf/_listener.py Outdated
Comment on lines +266 to +267
key = (data, sender_scope) if sender_scope else data
recent_time = self._recent_packets.get(key, -1e30)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/zeroconf/_listener.py Outdated
Comment on lines +266 to +267
key = (data, sender_scope) if sender_scope else data
recent_time = self._recent_packets.get(key, -1e30)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

halibsbot commented Oct 31, 2026 •

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@halibsbot halibsbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Important issues found.

  • Scope-aware dedup also lets dual-stack queries through, so one query is answered twice

bdraco and others added 2 commits October 30, 2026 18:24
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.
@codspeed

codspeed Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 24 untouched benchmarks


Comparing akiernan:fix/dedup-per-sender-scope (ffe6cfd) with master (bc25ee1)

Open in CodSpeed

@bdraco
bdraco requested a max review from Copilot October 31, 2026 04:35
@halibsbot

halibsbot commented Oct 31, 2026 •

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@halibsbot halibsbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

@bdraco

bdraco commented Oct 31, 2026

Copy link
Copy Markdown
Member

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 IPVersion.All, browsing all 36 service types on the link. On the 98 services both runs saw, scoping and query handling are unchanged. The PR adds one extra Updated event per service on a host with two interfaces on the same link.

Case 73aa8cf master 7c989c9
Services with a link-local AAAA (of 98 seen by both) 94 94
… whose fe80:: address has a %scope 72 72
… unscoped only (responders that answer over IPv4 only) 22 22
IPv6 address lists identical to the other build 98 / 98 —
Multicast responses sent for the test's own registered service 3 3
Updated events over the 98 services 157 97
Link-local AAAA cached under two scopes (45 s rerun) 18 of 21 0 of 21

Findings

  • On this network the dual-stack bug doesn't change the outcome: these responders' IPv6 copies already arrive first, so master scopes the same 72 services. The 22 unscoped-only services answer over IPv4 only, the case the PR description lists as out of scope.
  • Regression: this host has two interfaces on the same link, so every IPv6 copy arrives twice with different scopes. Master suppresses the second copy. The PR admits both, so each fe80:: AAAA is cached under both scopes. A cache-flush answer from one copy then marks the other scope's variant (if older than 1 s) to expire in 1 s, and the next copy re-adds it as a new record. That fires Updated again: 63 of the 98 services got more events than on master, 4 got fewer. This is the cache-flush point from the last review, made worse on a multihomed host.
  • Query dedup works: the responder sent the same number of multicast responses on both builds.
  • Fix: async_mark_unique_records_older_than_1s_to_expire should not expire an AAAA when the packet carries the same address under another scope. Coming next, then a rerun.
Setup
  • Host: macOS, CPython 3.14, Cython 3.3.0, every TO_CYTHONIZE module compiled for both trees. A single dual-stack :: listen socket (IPV6_V6ONLY off). Two active interfaces on the same link.
  • Network: about 100 services from Apple TVs, HomePods, AirPlay speakers, Thread border routers, Matter devices and HomeKit bridges.
  • Each process: AsyncZeroconf(ip_version=IPVersion.All); registers lantest-<build>._lantest._tcp.local.; finds every type with AsyncZeroconfServiceTypes.async_find; runs one AsyncServiceBrowser over all of them, counting state changes per service; wraps async_send to count responses that carry its own records. After 120 s it loads each service from the cache and records parsed_scoped_addresses(IPVersion.V6Only).
  • The 45 s rerun also walked the cache and grouped link-local AAAA records by (name, address), listing the scopes each was cached under.

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.
@bdraco

bdraco commented Oct 31, 2026

Copy link
Copy Markdown
Member

Still need some more work, lots of update event churn.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The scoped-replay test is bypassed by the single-slot fast path, so it passes even if scoped responses are never added to _recent_packets.

3 open issues
1 resolved since last review

Review effort: Max

Comment thread tests/test_listener.py Outdated
Comment on lines +312 to +315
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()
@bdraco

bdraco commented Oct 31, 2026

Copy link
Copy Markdown
Member

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

Copy link
Copy Markdown

@bdraco: Still need some more work, lots of update event churn.

668e745 stops flush re-add loop. First-seen variants still fire Updated: browser.py:634 sees old=None. Fire only for new address or gained scope. Also check goodbyes: _has_scope_variant ignores TTL 0.

@halibsbot

halibsbot commented Oct 31, 2026 •

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@halibsbot halibsbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Important issues found.

  • _has_scope_variant lets 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.
@bdraco

bdraco commented Oct 31, 2026

Copy link
Copy Markdown
Member

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 IPVersion.All. The extra Updated events are gone. The cache now holds each link-local address once, and on the 84 services both runs saw, the PR fires fewer Updated events than master.

Case 3fab5cb 73aa8cf (first run) master 7c989c9
Services seen by both builds 84 98 —
… with a %scope on their fe80:: address 62 72 62 / 72
… unscoped only (IPv4-only responders) 22 22 22
IPv6 address lists identical to master 84 / 84 98 / 98 —
Link-local AAAA cached under more than one scope 0 of 24 18 of 21 (45 s run) 0
Updated events on the shared services 36 157 64 (this run) / 97 (first run)
Multicast responses sent for the test's own service 3 3 3

What changed since the first run

  • One AAAA per address in the cache. RecordManager looks up the cached AAAA for the address under any scope (new DNSCache.async_get_address). A scoped copy replaces an unscoped one. Any other copy takes the cached scope and refreshes that record. A goodbye under any scope removes the cached copy. This replaces three narrower patches: the known-answer collapse, the cache-flush exemption in 668e745 (reverted), and a browser-side Updated filter that was never pushed.
  • A scoped copy suppresses later copies from any sender. Each duplicate-suppression entry records whether the processed copy was scoped, instead of keying on (payload, scope). Only an unscoped-first packet lets its scoped copy through, so a dual-stack or multihomed host parses each announcement once more at most, not on every repeat.
  • Cost on the merge path: an unscoped copy of an already-scoped AAAA now costs +74 ns per packet over an exact cache hit (812 vs 738 ns through async_updates_from_response), down from +165 ns. The duplicate path and AAAA parsing are unchanged.
  • New tests: one AAAA per address for each scope order, an unscoped refresh of a scoped record, goodbyes across scopes, a scoped copy suppressing later copies, and no Updated for a scope copy. The full suite passes with the Cython build and in pure Python (533 passed).
Setup
  • Same harness as the first run: each process registers its own _lantest._tcp service, finds every type with AsyncZeroconfServiceTypes.async_find, browses all of them with one AsyncServiceBrowser, counts state changes per service and the responses it sends for its own records, then reads parsed_scoped_addresses(IPVersion.V6Only) from the cache after 120 s and groups the link-local AAAA records in the cache by (name, address).
  • Host: macOS, CPython 3.14, Cython 3.3.0, all TO_CYTHONIZE modules compiled from a clean git archive of each commit. A single dual-stack :: listen socket, two interfaces on the same link.
  • The network had fewer services up during this run (84 shared vs 98), so compare ratios rather than absolute counts between runs.
  • Merge-path microbenchmark: a 1-AAAA response through RecordManager.async_updates_from_response, cache seeded with the scoped copy, best of 5 × 50 000.

@halibsbot

halibsbot commented Oct 31, 2026 •

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@halibsbot halibsbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@bdraco

bdraco commented Oct 31, 2026

Copy link
Copy Markdown
Member

Note

Automated testing results

Retested bf4f5b4 (scoped goodbyes now report a RecordUpdate, and the replaced unscoped copy is removed right away) against master 7c989c9 on the same network and host, both Cython-compiled and running side by side: one 120 s run and two 60 s runs. The PR finds exactly the same services as master. In one run it scoped 22 link-local addresses that master left unscoped, and every extra Updated event comes from those upgrades.

Case bf4f5b4 master 7c989c9
Services seen / added (60 s run 1) 192 / 179 192 / 179
Services seen / added (60 s run 2) 194 / 181 194 / 181
Services with a link-local AAAA, %scope on it (run 2) 50 of 50 28 of 50
Address lists that differ from master (run 2) 22 only gain a %scope, 1 scoped to the other interface —
Updated events on the 22 newly scoped services (run 2) 42 20
Updated events on the other 38 services (run 2) 14 13
Link-local AAAA cached under more than one scope (120 s) 0 of 24 0 of 24
Multicast responses sent for the test's own service (every run) 3 3

Findings

  • In run 2 the Home Assistant host's IPv6 copy arrived after its IPv4 copy. Master dropped it as a duplicate and kept those 22 services unscoped. The PR scoped them, and each upgrade fired one Updated, since the address became usable.
  • One service was scoped to the host's other interface (%15 instead of %31). Both are valid on this two-interface host; the cache keeps the first scope it sees.
  • Review follow-ups in bf4f5b4:
    • A scoped goodbye for an unscoped cached AAAA now takes the normal goodbye path and reports a RecordUpdate. test_aaaa_goodbye_under_another_scope_removes_cached asserts that for every scope order, and its (0, 2) case fails on 3fab5cb.
    • The replaced unscoped copy is removed immediately rather than staged, so a full cache that evicts it first cannot raise a KeyError in the remove step.
  • Not changed: first scope wins on purpose, with one cache for all interfaces. Letting a later scope win would make a multihomed host flap between interfaces. A stale scope after an interface change lasts until the record expires, which the description now states. The async_get_address bucket scan runs only on an AAAA cache miss and walks one hostname's records.
  • Full suite: 536 passed with the Cython build and in pure Python.
Setup
  • Same harness as the earlier runs: each process registers its own _lantest._tcp service, finds every type with AsyncZeroconfServiceTypes.async_find, browses all of them with one AsyncServiceBrowser, and counts state changes per service and the responses it sends for its own records. At the end it loads each service from the cache and records parsed_scoped_addresses(IPVersion.V6Only), and groups the link-local AAAA records in the cache by (name, address).
  • "Seen / added" counts services that produced any or an Added browser event. Only some of them are fully resolved in the cache when the timer ends, and that varies between runs, so the address rows compare only services both builds loaded.
  • Host: macOS, CPython 3.14, Cython 3.3.0, all TO_CYTHONIZE modules compiled from a clean git archive of each commit. A single dual-stack :: listen socket, two interfaces on the same link.

@bdraco

bdraco commented Oct 31, 2026

Copy link
Copy Markdown
Member

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

@halibsbot

halibsbot commented Oct 31, 2026 •

Copy link
Copy Markdown

PR Review — fix: don't suppress a scoped copy of a packet as a duplicate of an unscoped one

Merge-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 RecordUpdate, and test_aaaa_goodbye_under_another_scope_removes_cached asserts that for each scope order. The replaced unscoped copy is removed immediately, so eviction from a full cache can't cause a KeyError in the remove step. The PR description now matches the code, including the (time, scoped) dedup entries, the query exemption and the cache merge. Each _recent_packets entry stays bounded, and _set_scope_id runs before answers_rrset is built, so cache flush and goodbyes match the cached record.

I verified this by reading the code. I did not run the tests or a Cython build, because this review is read-only.

  • Own link-local AAAA records now miss the recently-multicast checks in both arrival orders, not only when the IPv6 copy arrives first

Not re-raised: first-scope-wins and a stale scope after an interface change. Dismissed per bdraco: "I think this is the best we can do without massively expanding scope to have a per interface cache." The limitation is documented in the PR description. The async_get_address scan is also not re-raised: the author answered that it runs only on an AAAA cache miss and walks one hostname's records.


✅ Resolved since last review (1)

Previously-flagged issues verified fixed
  • src/zeroconf/_handlers/record_manager.py:181 First scope wins forever, so a stale zone can outlive its interface


Checklist

  • No hardcoded secrets
  • No injection or unsafe deserialization
  • Collections remain bounded
  • Cython declarations updated with the Python change
  • Goodbye and cache-flush semantics preserved (RFC 6762 section 10.2)
  • Listener notification consistent across goodbye paths
  • Rate limiting of own records preserved (RFC 6762 sections 5.4 and 14)
  • New behaviour covered by tests, including edge paths
  • PR description matches the code
ℹ️ Triage summary

1 pre-existing finding(s) on unchanged code suppressed (freeze).


Silent Failure Analysis

🟡 **1. MEDIUM** — fallback value hides conflicting state
src/zeroconf/_handlers/record_manager.py:175-184

Risk: When an fe80:: AAAA arrives on a different non-zero scope, its scope is silently overwritten with the cached one and nothing is logged, so a host that moves interfaces keeps its stale scope for as long as refreshes continue (each refresh is pinned to the old scope), and because fe80:: addresses are only unique per link, a goodbye from one link also removes a record still valid on another.

if record.scope_id and not cached.scope_id and record.ttl:
    self.cache.async_remove_records((cached,))
    return None
record._set_scope_id(cached.scope_id)
return cached

Fix: When both scopes are non-zero and differ, have the newest non-goodbye record replace the cached scope and report the change, ignore goodbyes whose scope conflicts with the cached one, and log the conflict at debug level.


Automated review by Kōan (Claude) HEAD=ffe6cfd 2 min 13s

@bdraco

bdraco commented Oct 31, 2026

Copy link
Copy Markdown
Member

@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 halibsbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

@akiernan

Copy link
Copy Markdown
Contributor Author

Retested ffe6cfd on the device where I first saw this: Linux 6.12, Python 3.14, pure Python, one interface, IPVersion.All. The control was the PR's merge base on master (bc25ee1). The two ran side by side for 300 s, browsing every service type on the link. The cleanups didn't break our use case.

Link-local AAAA with a %scope ffe6cfd master
All services (103 with a link-local address) 99 15
The 60 services of 19 embedded streamers running Apple's mDNSResponder 60 6

On Linux the IPv4 copy almost always reaches the :: socket first, from a v4-mapped sender. So master loses the scoped IPv6 copy on most services, rather than only when arrival order goes the wrong way. Both builds found the same 125 services. No name had its address cached under two scopes.

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 fe80::, so their AAAA records arrive with scope 0. Another responder on the HA host answers from fe80:: and was scoped on its first answer. Scoping these needs the receiving interface (#1568 or IP_PKTINFO), the limitation in the description.

Thanks for taking this on.

Written by Claude, posted on Alex's behalf.

@bdraco
bdraco merged commit 737c667 into python-zeroconf:master Oct 31, 2026
47 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.

4 participants