feat: add async_update_interfaces to rescan network interfaces at runtime#1797
Conversation
37e3ceb to
530b7ff
Compare
cc6dd24 to
f624cb2
Compare
f624cb2 to
f7b6624
Compare
There was a problem hiding this comment.
Pull request overview
Adds a runtime API to rescan and reconcile Zeroconf’s active sockets with the current set of network interfaces, avoiding the need to recreate a Zeroconf instance when interfaces appear/disappear.
Changes:
- Add
async_update_interfaces()(and syncupdate_interfaces()) to reconcile per-interface responder sockets and re-announce existing registrations. - Introduce
drop_multicast_member()as the inverse ofadd_multicast_member()for proactive multicast membership teardown. - Add targeted test coverage for interface updates and multicast leave behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/zeroconf/_core.py |
Stores construction parameters and adds sync/async interface-rescan APIs plus re-announce behavior. |
src/zeroconf/_engine.py |
Implements sender/socket reconciliation logic and multicast membership add/drop integration. |
src/zeroconf/_utils/net.py |
Adds drop_multicast_member() to leave multicast groups with benign-error handling. |
src/zeroconf/asyncio.py |
Adds AsyncZeroconf.async_update_interfaces() wrapper delegating to Zeroconf. |
tests/test_interface_update.py |
New tests for interface diffing helpers and runtime interface update scenarios. |
tests/utils/test_net.py |
Adds unit tests for drop_multicast_member(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
bluetoothbot
left a comment
There was a problem hiding this comment.
Blocking issues found.
- IPv6 link-local diff key collapses interfaces sharing an address
- drop_multicast_member omits WSAEINVAL handled by its inverse
f7b6624 to
14d468f
Compare
|
Thanks, both review points addressed in the latest push: IPv6 link-local diff key collision (bluetoothbot + Copilot): drop_multicast_member WSAEINVAL (bluetoothbot + Copilot): now mirrors Also added |
14d468f to
9c65842
Compare
Verified. Scope_id keying sound. Desired reads |
|
|
bluetoothbot
left a comment
There was a problem hiding this comment.
No blocking issues found.
4029b73 to
b2f6c89
Compare
|
|
bluetoothbot
left a comment
There was a problem hiding this comment.
Blocking issues found.
- IPv6 multicast leave uses scope_id where the join used the interface index
b2f6c89 to
320f800
Compare
|
Good catch on the IPv6 membership asymmetry — fixed in the latest push. Important #1 (IPv6 leave used scope_id where join used the index): the responder socket already holds the join index in New tests:
Suggestion #1: Suggestion #3 / silent partial failure: |
bluetoothbot
left a comment
There was a problem hiding this comment.
Blocking issues found.
- getsockopt re-raise widens the startup failure surface for every IPv6 transport
make_wrapped_transport runs on the startup/connection path; re-raising a failed IPV6_MULTICAST_IF read there could abort instance startup to protect multicast_index, which only selects the interface for a benign group leave that drop_multicast_member already tolerates. Fall back to the default index with a debug log instead. Apply the same graceful fallback to _listen_socket_supports (assume dual-stack) so a getsockopt failure can't abort a rescan, and the two paths agree. Soften the _core config-commit comment, which overstated the invariant on a partial reconcile failure.
|
Good catch on the startup blast radius. Fixed in 37f6b1a:
On the two silent-failure notes (surface failed/degraded interfaces to the caller): keeping that best-effort by design for now; the per-interface warnings name the interface, and an aggregate failed-interface return is a deliberate API addition I'd do as a follow-up rather than fold into this PR. |
|
|
bluetoothbot
left a comment
There was a problem hiding this comment.
No blocking issues found.
…rt bring-up A failed IPV6_V6ONLY read assumes dual-stack (returning False could loop rebuilds when the rebuilt socket's read also fails), but if the socket really were v6-only that skips a needed rebuild and strands an added IPv4 family, so log it at warning rather than debug. Document that interface bring-up is best-effort, and note the benign group leave on the rebuilt listen socket.
|
Addressed in c93f537:
The programmatic partial-reconcile signal (return failed interfaces) stays a deliberate follow-up, as you noted; no API change in this PR. |
|
|
bluetoothbot
left a comment
There was a problem hiding this comment.
Blocking issues found.
- normalize_interface_choice can raise RuntimeError on an empty interface set, bypassing best-effort contract
… bind failure normalize_interface_choice raises RuntimeError for an All/Default instance that transiently resolves to zero addresses during adapter churn; catch it as a logged no-op so a momentary down state doesn't crash a caller's adapter-change handler. Make a per-interface endpoint-creation failure roll back and skip (log_warning_once) rather than abort the whole reconcile, so other interfaces still come up and get re-announced, matching the documented best-effort contract. Log a non-Windows IPV6_MULTICAST_IF read failure at warning (Windows WSAEINVAL stays debug) since a wrong-index leave leaks the membership.
|
All three addressed in 423f875:
|
|
|
bluetoothbot
left a comment
There was a problem hiding this comment.
No blocking issues found.
Best-effort bring-up should downgrade an expected socket-level failure, not a real bug. Roll back on any wrap failure, but re-raise anything that is not an OSError so a TypeError/AttributeError surfaces instead of being deduped into a one-time 'interface not added' warning.
|
On the two notes: MEDIUM #1 (bare Exception) - fixed in 55ca5ea. Good catch; that was a regression from making the wrap failure best-effort. _async_add_interface now rolls back on any failure but only swallows OSError; a non-OSError (TypeError/AttributeError etc.) propagates so a real bug isn't downgraded to a deduped 'interface not added' warning. Added a test that a TypeError propagates after rollback. MEDIUM #2 (_listen_socket_supports assume-dual-stack) - holding deliberately. This exact line has now been flagged in four directions across reviews (silent True, re-raise on non-win32, degrade to True, warning, now force-rebuild). force-rebuild is not strictly better: returning False on a getsockopt failure rebuilds to a dual-stack AF_INET6 socket whose IPV6_V6ONLY read fails the same way, so the next rescan rebuilds again - churning the listen socket on every reconcile. getsockopt(IPV6_V6ONLY) on a valid AF_INET6 socket effectively never fails on any platform, so this branch is defensive against the near-impossible; assume-dual-stack is the non-looping choice and the warning makes the rare event visible. A real fix (cache V6ONLY at socket creation, or a caller-visible degraded-family signal) is the deferred API follow-up, not a log-level or fallback-direction tweak. Leaving as-is unless a maintainer wants the churn tradeoff. |
Verified rebuild rollback and staying-interface re-join — sound. One gap: rebuild failure raises through the best-effort API after demoting the dual-use socket from senders. Leaves degraded partial state, crashes adapter-change handler. See inline comment. Demote after rebuild succeeds, or log-and-return-False like the transient empty-set branch. |
|
|
bluetoothbot
left a comment
There was a problem hiding this comment.
Blocking issues found.
- Rebuild failure raises through a best-effort API after mutating sender state
The Default dual-use conversion demoted the socket from senders before the fallible rebuild, so a rebuild failure left it pulled from senders but not replaced (instance stops responding) and propagated into the caller's handler. The explicit demote was redundant; the rebuild removes the old transport from senders itself, but only after the new socket succeeds. Drop the pre-demote (keep only the diff-view pop) so a failed rebuild leaves senders intact, and catch the rebuild failure as a logged no-op to honor the best-effort contract.
|
Important #1 - fixed in f0090e4. Genuinely good catch. The Default dual-use conversion demoted the socket from senders before the fallible rebuild, so a rebuild failure left it pulled-but-not-replaced (instance goes silent) and the exception crashed the caller's handler. The fix: that explicit demote was redundant. _async_rebuild_listen_socket already removes the old transport from senders via _async_remove_transport, but only after the new socket is successfully created and wrapped. So I dropped the pre-demote (keeping only the local diff-view current.pop) and wrapped the rebuild in try/except -> log + return False. Now a rebuild failure (transient bind/endpoint failure during churn) leaves senders untouched (no degraded state) and is a logged no-op rather than a crash, consistent with the transient-empty-set branch. Added tests for both the family-rebuild failure and the dual-use-conversion failure leaving the dual-use sender intact. Thanks for confirming the MEDIUM #2 hold (assume-dual-stack) - agreed, force-rebuild would churn. The remaining silent-failure note (send-only after a failed re-join) and the partial-failure return value stay the documented best-effort follow-up. |
|
|
bluetoothbot
left a comment
There was a problem hiding this comment.
No blocking issues found.
gather(return_exceptions=True) also captures BaseExceptions like CancelledError, which the isinstance(result, Exception) check skipped, so a cancelled re-announce vanished silently; re-raise a captured BaseException instead of swallowing it. Document on the public update_interfaces methods that apple_p2p on a non-Apple platform raises RuntimeError.
|
Addressed the two actionable items in c2df902:
MEDIUM #1 (assume-dual-stack) and #2 (send-only after failed re-join) remain held as the documented best-effort tradeoffs we agreed on; the aggregate partial-failure return is the post-PR follow-up. |
bluetoothbot
left a comment
There was a problem hiding this comment.
No blocking issues found.

Closes #1777.
Summary
Adds a runtime API to reconcile the sockets zeroconf uses to the live interface set, so an interface that appears or disappears no longer requires recreating the whole
Zeroconf.async_update_interfaces(with a syncupdate_interfaceswrapper) adds a responder socket for each interface that showed up, tears down the socket for each interface that went away, and re-announces existing registrations when a new sender appeared.It accepts the same
interfaces,ip_version, andapple_p2parguments as the constructor, each defaulting to the construction-time value, so a caller reconfigures with the same vocabulary it started with. The listen socket is rebuilt when the new set needs a different address family; unicast mode is fixed at construction.How this maps to #1777
interfaces,ip_version,apple_p2p.(address, scope_id); only senders whose key left the desired set are closed, only keys that are new get a socket. An IP change is remove-old + add-new.Behavior
Details
Zeroconf.__init__retainsinterfaces,ip_version, andapple_p2p; the rescan re-runsnormalize_interface_choiceagainst the live set and brings each interface up through the samenet.add_interfaceprimitive construction uses, so setup and rescan stay in lockstep.(address, scope_id)keying lives on_WrappedTransport.interface_key(paired with_interface_keyfor the desired side), so link-local IPv6 addresses that repeat across interfaces don't collide;_WrappedTransport.multicast_interfacegives the value membership calls take.drop_multicast_memberinnet.pyis the inverse ofadd_multicast_member(IP_DROP_MEMBERSHIP / IPV6_LEAVE_GROUP), swallowing the same benign errnos (including WSAEINVAL on Windows)._async_broadcast_service; the listener answers on whichever transport received the query, so a new reader needs no extra wiring. RFC 6762 section 8.3.AsyncZeroconf.async_update_interfacesdelegates toZeroconf, mirroringasync_register_service.How a consumer drives this (e.g. Home Assistant)
A consumer that constructs
Zeroconf(interfaces=[...])with an explicit address list callsasync_update_interfaces(new_list)from its own adapter-change signal; the diff reconciles the new list against the bound sockets and leaves unchanged ones in place. Consumers with native adapter-change events use this directly; those without can opt into the polling monitor in the follow-up PR. This is the socket-side half of cases like home-assistant/core#59553 (it re-announces whateverServiceInfothe consumer registered; correcting announced URL/TXT content after a network change is the consumer's job viaasync_update_service).Test plan
tests/test_interface_update.pycovers no-op (no sockets touched, no re-announce), default-to-stored-choice, ip_version/apple_p2p overrides, keep-unchanged-sender-untouched (same transport object), remove and re-add, re-announce on add, failed join, None responder rollback, the dual-use guard, the unicast no-listen-socket path, and the sync wrapper, plus_WrappedTransport.interface_key/multicast_interface/_interface_keyunit tests.test_update_interfaces_reconciles_mixed_setdrives the engine diff over a 4-sender v4+v6 set in one pass: keeps unchanged, drops gone, adds new, and distinguishes two interfaces sharingfe80::1by scope_id (scope-2 kept, scope-3 dropped) end to end, asserting each gone interface leaves its group with its own representation.tests/utils/test_net.py::test_drop_multicast_membercovers success, IPv6, benign errnos, reraise, no-IPv6, and the Windows WSAEINVAL path._engineand_transportdiff fully covered (lines and branches); compiled (REQUIRE_CYTHON=1) and pure-Python suites green;pre-commit runclean.