From 48c5fe5da6145306e3d40159b940de42546bd638 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 18:49:24 -0500 Subject: [PATCH 01/23] feat: add async_update_interfaces to rescan network interfaces at runtime --- src/zeroconf/_core.py | 71 ++++++ src/zeroconf/_engine.py | 139 +++++++++-- src/zeroconf/_transport.py | 54 ++++- src/zeroconf/_utils/net.py | 37 +++ src/zeroconf/asyncio.py | 16 ++ tests/test_interface_update.py | 412 +++++++++++++++++++++++++++++++++ tests/utils/test_net.py | 40 ++++ 7 files changed, 753 insertions(+), 16 deletions(-) create mode 100644 tests/test_interface_update.py diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index f184b6fa..88520a15 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -199,6 +199,11 @@ def __init__( self.unicast = unicast self._use_asyncio = use_asyncio + # Retained so async_update_interfaces can re-run create_sockets / + # normalize_interface_choice against the live interface set later. + self._interfaces = interfaces + self._ip_version = ip_version + self._apple_p2p = apple_p2p listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) log.debug("Listen socket %s, respond sockets %s", listen_socket, respond_sockets) @@ -216,6 +221,9 @@ def __init__( self.record_manager = RecordManager(self) self._notify_futures: set[asyncio.Future] = set() + # Serializes async_update_interfaces so overlapping calls (a bursty + # adapter-change source) don't diff against a stale sender snapshot. + self._interface_update_lock = asyncio.Lock() self.loop: asyncio.AbstractEventLoop | None = None self._loop_thread: threading.Thread | None = None @@ -406,6 +414,69 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: self.registry.async_update(info) return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None)) + def update_interfaces( + self, + interfaces: InterfacesType | None = None, + ip_version: IPVersion | None = None, + apple_p2p: bool | None = None, + ) -> None: + """Rescan network interfaces and reconcile the sockets in use. + + While it is not expected during normal operation, + this function may raise EventLoopBlocked if the underlying + call to `async_update_interfaces` cannot be completed. + """ + assert self.loop is not None + run_coro_with_timeout( + self.async_update_interfaces(interfaces, ip_version, apple_p2p), + self.loop, + _REGISTER_TIME * _REGISTER_BROADCASTS, + ) + + async def async_update_interfaces( + self, + interfaces: InterfacesType | None = None, + ip_version: IPVersion | None = None, + apple_p2p: bool | None = None, + ) -> None: + """Rescan network interfaces and reconcile the sockets in use. + + Adds sockets for interfaces that appeared, drops sockets for + interfaces that disappeared, and re-announces existing + registrations when a new sender appeared. ``interfaces``, + ``ip_version`` and ``apple_p2p`` each default to the value passed at + construction; pass a new value to switch it. When the resulting + interface set is unchanged this is a no-op (no sockets touched, + nothing re-announced). The shared listen socket's family and unicast + mode are fixed at construction. Concurrent calls are serialized. + """ + # Resolve against the retained config but only commit it after the + # engine reconcile succeeds, so a failed reconcile leaves the stored + # values matching the sockets actually bound. + interfaces = self._interfaces if interfaces is None else interfaces + ip_version = self._ip_version if ip_version is None else ip_version + apple_p2p = self._apple_p2p if apple_p2p is None else apple_p2p + await self.async_wait_for_start() + async with self._interface_update_lock: + added = await self.engine.async_update_interfaces(interfaces, ip_version, apple_p2p) + self._interfaces = interfaces + self._ip_version = ip_version + self._apple_p2p = apple_p2p + if not added: + return + # Re-announce every registration; one broadcast failing must not + # mask the rest, so collect exceptions and log them individually. + results = await asyncio.gather( + *[ + self._async_broadcast_service(info, _REGISTER_TIME, None) + for info in self.registry.async_get_service_infos() + ], + return_exceptions=True, + ) + for result in results: + if isinstance(result, Exception): + log.warning("Error re-announcing service after interface update: %s", result) + async def async_get_service_info( self, type_: str, diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 0e1c01a1..13a3a550 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -30,6 +30,14 @@ from ._record_update import RecordUpdate from ._utils.asyncio import get_running_loop, run_coro_with_timeout +from ._utils.net import ( + InterfacesType, + IPVersion, + add_multicast_member, + drop_multicast_member, + new_respond_socket, + normalize_interface_choice, +) from ._utils.time import current_time_millis from .const import _CACHE_CLEANUP_INTERVAL @@ -38,17 +46,30 @@ from ._listener import AsyncListener -from ._transport import _WrappedTransport, make_wrapped_transport +from ._transport import _strip_zone, _WrappedTransport, make_wrapped_transport _CLOSE_TIMEOUT = 3000 # ms +def _interface_key(interface: str | tuple[tuple[str, int, int], int]) -> tuple[str, int]: + """Return the (address, scope_id) an interface choice maps to, for diffing. + + Must produce the same key shape as ``_WrappedTransport.interface_key`` so + the desired set (from ``normalize_interface_choice``) and the current set + (from the bound senders) diff against each other. + """ + if isinstance(interface, tuple): + return (_strip_zone(interface[0][0]), interface[0][2]) + return (interface, 0) + + class AsyncEngine: """An engine wraps sockets in the event loop.""" __slots__ = ( "_cleanup_timer", "_listen_socket", + "_listen_transport", "_respond_sockets", "_setup_task", "loop", @@ -72,6 +93,7 @@ def __init__( self.senders: list[_WrappedTransport] = [] self.running_future: asyncio.Future[bool | None] | None = None self._listen_socket = listen_socket + self._listen_transport: _WrappedTransport | None = None self._respond_sockets = respond_sockets self._cleanup_timer: asyncio.TimerHandle | None = None self._setup_task: asyncio.Task[None] | None = None @@ -98,8 +120,6 @@ async def _async_setup(self, loop_thread_ready: threading.Event | None) -> None: async def _async_create_endpoints(self) -> None: """Create endpoints to send and receive.""" - assert self.loop is not None - loop = self.loop reader_sockets = [] sender_sockets = [] if self._listen_socket: @@ -110,22 +130,113 @@ async def _async_create_endpoints(self) -> None: sender_sockets.append(s) for s in reader_sockets: - transport, protocol = await loop.create_datagram_endpoint( # type: ignore[type-var] - lambda: AsyncListener(self.zc), # type: ignore[arg-type, return-value] - sock=s, - ) - # Register the wrapped transport before releasing the engine's - # handle so a concurrent shutdown always sees ``s`` in exactly - # one place; do not add an ``await`` between these two steps. - self.protocols.append(cast(AsyncListener, protocol)) - self.readers.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) - if s in sender_sockets: - self.senders.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport))) + reader = await self._async_wrap_socket(s, s in sender_sockets) + # The wrap above does not await before returning, so releasing + # the engine's pending handle here keeps ``s`` in exactly one + # place from a concurrent shutdown's point of view. if s is self._listen_socket: + # Keep a handle to the shared listen socket so interface + # rescans can add/drop multicast memberships on it. + self._listen_transport = reader self._listen_socket = None if s in self._respond_sockets: self._respond_sockets.remove(s) + async def _async_wrap_socket(self, sock: socket.socket, is_sender: bool) -> _WrappedTransport: + """Adopt a socket into a transport, register it, and return the reader wrapper.""" + assert self.loop is not None + transport, protocol = await self.loop.create_datagram_endpoint( # type: ignore[type-var] + lambda: AsyncListener(self.zc), # type: ignore[arg-type, return-value] + sock=sock, + ) + datagram_transport = cast(asyncio.DatagramTransport, transport) + reader = make_wrapped_transport(datagram_transport) + # No ``await`` between wrapping and registering so a concurrent + # shutdown always sees the transport in exactly one place. + self.protocols.append(cast(AsyncListener, protocol)) + self.readers.append(reader) + if is_sender: + self.senders.append(make_wrapped_transport(datagram_transport)) + return reader + + async def async_update_interfaces( + self, + interfaces: InterfacesType, + ip_version: IPVersion, + apple_p2p: bool, + ) -> bool: + """Reconcile sender/reader sockets to the live interface set. + + Adds a per-interface responder socket for each interface that + appeared and tears down the socket for each interface that + disappeared, diffing on the bound address. The shared listen + socket (including the Default single-family dual-use socket) is + never torn down here. Returns whether any responder socket was + added, so the caller can skip re-announcing when nothing appeared. + """ + assert self.loop is not None + normalized = normalize_interface_choice(interfaces, ip_version) + desired = {_interface_key(interface): interface for interface in normalized} + current = {wrapped.interface_key: wrapped for wrapped in self.senders} + listen_transport = self._listen_transport + listen_socket = listen_transport.sock if listen_transport is not None else None + + for bind_address, wrapped in current.items(): + if bind_address in desired: + continue + if listen_transport is not None and wrapped.transport is listen_transport.transport: + # The shared listen / dual-use socket is not a per-interface + # sender; leaving the group or closing it would break receive. + continue + self._async_close_sender(wrapped, listen_socket) + + added = False + for bind_address, interface in desired.items(): + if bind_address in current: + continue + if await self._async_add_interface(interface, listen_socket, apple_p2p): + added = True + return added + + async def _async_add_interface( + self, + interface: str | tuple[tuple[str, int, int], int], + listen_socket: socket.socket | None, + apple_p2p: bool, + ) -> bool: + """Join the multicast group and adopt a responder socket for one interface. + + Returns whether a responder socket was actually added. + """ + # A unicast instance has no listen socket, so membership is only + # ever managed when ``listen_socket`` is present. These are + # user-initiated reconciles, so a requested interface that fails to + # come up is surfaced once at warning (deduped per interface so the + # polling monitor doesn't spam) rather than only at debug. + if listen_socket is not None and not add_multicast_member(listen_socket, interface): + self.zc.log_warning_once(f"Interface {interface!r} not added: could not join multicast group") + return False + respond_socket = new_respond_socket(interface, apple_p2p=apple_p2p, unicast=self.zc.unicast) + if respond_socket is None: + if listen_socket is not None: + drop_multicast_member(listen_socket, interface) + self.zc.log_warning_once(f"Interface {interface!r} not added: no responder socket") + return False + await self._async_wrap_socket(respond_socket, is_sender=True) + return True + + def _async_close_sender(self, wrapped: _WrappedTransport, listen_socket: socket.socket | None) -> None: + """Drop a per-interface sender's wrappers/protocol and close its transport.""" + transport = wrapped.transport + self.protocols = [ + p for p in self.protocols if p.transport is None or p.transport.transport is not transport + ] + self.readers = [w for w in self.readers if w.transport is not transport] + self.senders = [w for w in self.senders if w.transport is not transport] + if listen_socket is not None: + drop_multicast_member(listen_socket, wrapped.multicast_interface) + transport.close() + def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" now = current_time_millis() diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index c8d7699b..8a68c44c 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -24,6 +24,13 @@ import asyncio import socket +from typing import cast + + +def _strip_zone(address: str) -> str: + """Drop a ``%zone`` suffix from an IPv6 address string.""" + percent = address.find("%") + return address[:percent] if percent != -1 else address class _WrappedTransport: @@ -32,6 +39,7 @@ class _WrappedTransport: __slots__ = ( "fileno", "is_ipv6", + "multicast_index", "sock", "sock_name", "transport", @@ -44,25 +52,67 @@ def __init__( sock: socket.socket, fileno: int, sock_name: tuple, + multicast_index: int = 0, ) -> None: """Initialize the wrapped transport. - These attributes are used when sending packets. + ``multicast_index`` is the IPV6_MULTICAST_IF interface index the + sender joined the group with, carried so a group leave uses the same + index the join did (the bound socket's scope_id is 0 for global IPv6). """ self.transport = transport self.is_ipv6 = is_ipv6 self.sock = sock self.fileno = fileno self.sock_name = sock_name + self.multicast_index = multicast_index + + @property + def interface_key(self) -> tuple[str, int]: + """The bound (address, scope_id) identifying this sender's interface. + + Used to diff senders against the desired interface set. The scope_id + keeps link-local IPv6 addresses that repeat across interfaces (same + address, different zone) from colliding to one key. + """ + sock_name = self.sock_name + if self.is_ipv6: + scope_id = cast(int, sock_name[3]) if len(sock_name) > 3 else 0 + return (_strip_zone(sock_name[0]), scope_id) + return (cast(str, sock_name[0]), 0) + + @property + def multicast_interface(self) -> str | tuple[tuple[str, int, int], int]: + """The interface value a group leave takes for this transport. + + For IPv6 this carries ``multicast_index`` (the index the join used), + not the bound scope_id, so leave and join stay symmetric. + """ + address, _scope_id = self.interface_key + if self.is_ipv6: + return ((address, 0, 0), self.multicast_index) + return address def make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTransport: """Make a wrapped transport.""" sock: socket.socket = transport.get_extra_info("socket") + is_ipv6 = sock.family == socket.AF_INET6 + multicast_index = 0 + if is_ipv6: + # IPV6_MULTICAST_IF holds the interface index new_respond_socket + # joined the group with; capture it so a later group leave uses the + # same index. Windows rejects reading the option (WSAEINVAL); there + # the leave falls back to the default interface as it did before. + try: + multicast_index = sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF) + except OSError: + multicast_index = 0 return _WrappedTransport( transport=transport, - is_ipv6=sock.family == socket.AF_INET6, + is_ipv6=is_ipv6, sock=sock, fileno=sock.fileno(), sock_name=sock.getsockname(), + multicast_index=multicast_index, ) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 01c5b040..656f0508 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -401,6 +401,43 @@ def add_multicast_member( return True +def drop_multicast_member( + listen_socket: socket.socket, + interface: str | tuple[tuple[str, int, int], int], +) -> bool: + """Leave the mDNS multicast group on an interface; inverse of add_multicast_member.""" + # This is based on assumptions in normalize_interface_choice + is_v6 = isinstance(interface, tuple) + log.debug("Dropping %r (socket %d) from multicast group", interface, listen_socket.fileno()) + try: + if is_v6: + try: + mdns_addr6_bytes = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6) + except OSError: + log.info( + "Unable to translate IPv6 address when dropping %s from multicast group, " + "this can happen if IPv6 is disabled on the system", + interface, + ) + return False + iface_bin = struct.pack("@I", cast(int, interface[1])) + listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_LEAVE_GROUP, mdns_addr6_bytes + iface_bin) + else: + _value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(cast(str, interface)) + listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, _value) + except OSError as e: + # The kernel drops memberships automatically when an interface + # disappears, so a stale leave is expected to fail benignly. + benign = {errno.EADDRNOTAVAIL, errno.EINVAL, errno.ENODEV, errno.ENOPROTOOPT} + if sys.platform == "win32": + # No WSAEINVAL definition in typeshed + benign.add(cast(Any, errno).WSAEINVAL) # pylint: disable=no-member + if get_errno(e) in benign: + return False + raise + return True + + def new_respond_socket( interface: str | tuple[tuple[str, int, int], int], apple_p2p: bool = False, diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 45aac67a..ed1bd60c 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -224,6 +224,22 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable: """ return await self.zeroconf.async_update_service(info) + async def async_update_interfaces( + self, + interfaces: InterfacesType | None = None, + ip_version: IPVersion | None = None, + apple_p2p: bool | None = None, + ) -> None: + """Rescan network interfaces and reconcile the sockets in use. + + Adds sockets for interfaces that appeared, drops sockets for + interfaces that disappeared, and re-announces existing + registrations on the resulting senders. ``interfaces``, + ``ip_version`` and ``apple_p2p`` each default to the construction-time + value. + """ + await self.zeroconf.async_update_interfaces(interfaces, ip_version, apple_p2p) + async def async_close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py new file mode 100644 index 00000000..222cfb66 --- /dev/null +++ b/tests/test_interface_update.py @@ -0,0 +1,412 @@ +"""Unit tests for runtime interface rescanning (async_update_interfaces).""" + +from __future__ import annotations + +import asyncio +import logging +import socket +from typing import cast +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from zeroconf import IPVersion, ServiceInfo, Zeroconf, _engine +from zeroconf._engine import _interface_key +from zeroconf._transport import _strip_zone, _WrappedTransport, make_wrapped_transport +from zeroconf._utils.net import InterfaceChoice +from zeroconf.asyncio import AsyncZeroconf + + +def _make_wrapped( + sock_name: tuple, + is_ipv6: bool = False, + transport: object | None = None, + multicast_index: int = 0, +) -> _WrappedTransport: + """Build a _WrappedTransport with mocked socket/transport for diff tests.""" + return _WrappedTransport( + transport=cast("asyncio.DatagramTransport", transport or Mock()), + is_ipv6=is_ipv6, + sock=cast("socket.socket", Mock()), + fileno=0, + sock_name=sock_name, + multicast_index=multicast_index, + ) + + +def test_strip_zone() -> None: + assert _strip_zone("fe80::1%eth0") == "fe80::1" + assert _strip_zone("192.168.1.5") == "192.168.1.5" + + +def test_interface_key() -> None: + assert _interface_key("192.168.1.5") == ("192.168.1.5", 0) + assert _interface_key((("fe80::1%eth0", 0, 7), 2)) == ("fe80::1", 7) + # The same link-local address on two interfaces must not collapse to one key. + assert _interface_key((("fe80::1", 0, 2), 2)) != _interface_key((("fe80::1", 0, 3), 3)) + + +def test_wrapped_interface_key() -> None: + assert _make_wrapped(("192.168.1.5", 5353)).interface_key == ("192.168.1.5", 0) + assert _make_wrapped(("fe80::1%eth0", 5353, 0, 7), True).interface_key == ("fe80::1", 7) + # A short sock_name (no scope_id) falls back to interface index 0. + assert _make_wrapped(("fe80::1", 5353), True).interface_key == ("fe80::1", 0) + + +def test_wrapped_multicast_interface() -> None: + assert _make_wrapped(("192.168.1.5", 5353)).multicast_interface == "192.168.1.5" + # IPv6 leave carries the join index (IPV6_MULTICAST_IF), not the bound + # scope_id (here sock_name scope_id 5 differs from multicast_index 9). + wrapped = _make_wrapped(("fe80::1", 5353, 0, 5), is_ipv6=True, multicast_index=9) + assert wrapped.multicast_interface == (("fe80::1", 0, 0), 9) + + +def test_make_wrapped_transport_reads_v6_multicast_index() -> None: + """make_wrapped_transport reads IPV6_MULTICAST_IF as the v6 join index.""" + sock = Mock() + sock.family = socket.AF_INET6 + sock.fileno.return_value = 0 + sock.getsockname.return_value = ("fe80::1", 5353, 0, 0) + sock.getsockopt.return_value = 5 + transport = Mock() + transport.get_extra_info.return_value = sock + wrapped = make_wrapped_transport(transport) + assert wrapped.is_ipv6 is True + assert wrapped.multicast_index == 5 + sock.getsockopt.assert_called_once_with(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF) + + +def test_make_wrapped_transport_unreadable_multicast_index() -> None: + """A socket that rejects reading IPV6_MULTICAST_IF (Windows) falls back to index 0.""" + sock = Mock() + sock.family = socket.AF_INET6 + sock.fileno.return_value = 0 + sock.getsockname.return_value = ("fe80::1", 5353, 0, 0) + sock.getsockopt.side_effect = OSError + transport = Mock() + transport.get_extra_info.return_value = sock + assert make_wrapped_transport(transport).multicast_index == 0 + + +@pytest.mark.asyncio +async def test_update_interfaces_noop(aiozc_loopback: AsyncZeroconf) -> None: + """Re-scanning the same interface set leaves the engine lists unchanged.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + before = (len(engine.senders), len(engine.readers), len(engine.protocols)) + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + assert (len(engine.senders), len(engine.readers), len(engine.protocols)) == before + + +@pytest.mark.asyncio +async def test_update_interfaces_defaults_to_stored_choice(aiozc_loopback: AsyncZeroconf) -> None: + """Calling without an argument reuses the interface choice from construction.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + before = (len(engine.senders), len(engine.readers), len(engine.protocols)) + await aiozc_loopback.async_update_interfaces() + assert (len(engine.senders), len(engine.readers), len(engine.protocols)) == before + + +@pytest.mark.asyncio +async def test_update_interfaces_accepts_ip_version_and_apple_p2p(aiozc_loopback: AsyncZeroconf) -> None: + """ip_version and apple_p2p overrides are stored for the rescan.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + await aiozc_loopback.async_update_interfaces(["127.0.0.1"], ip_version=IPVersion.V4Only, apple_p2p=False) + assert zc._ip_version is IPVersion.V4Only + assert zc._apple_p2p is False + + +@pytest.mark.asyncio +async def test_update_interfaces_removes_and_readds(aiozc_loopback: AsyncZeroconf) -> None: + """A gone interface drops its sender; a returning interface re-adds it.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + listen_reader_count = len(engine.readers) - len(engine.senders) + + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + assert engine.senders == [] + # The shared listen socket is never torn down. + assert len(engine.readers) == listen_reader_count + + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + await asyncio.sleep(0) + assert len(engine.senders) == 1 + + +@pytest.mark.asyncio +async def test_update_interfaces_keeps_unchanged_sender_untouched(aiozc_loopback: AsyncZeroconf) -> None: + """An unchanged interface keeps its exact transport; only the gone interface is torn down.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + kept = engine.senders[0] + kept_transport = kept.transport + + # Inject a sender for an interface that is absent from the new set. + gone_transport = Mock() + gone = _make_wrapped(("10.0.0.5", 5353), transport=gone_transport) + engine.senders.append(gone) + + with patch.object(_engine, "drop_multicast_member"): + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + await asyncio.sleep(0) + + # The unchanged 127.0.0.1 sender is the same object, never recreated. + assert engine.senders == [kept] + assert engine.senders[0].transport is kept_transport + # The gone interface's transport was closed exactly once. + gone_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_update_interfaces_reconciles_mixed_set(aiozc_loopback: AsyncZeroconf) -> None: + """One rescan keeps unchanged, drops gone, adds new across v4 and link-local v6. + + Drives the engine diff directly over a controlled sender set (no real + sockets) so the (address, scope_id) keying is exercised end to end, + including two interfaces sharing fe80::1 distinguished only by scope. + """ + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + + keep_v4 = _make_wrapped(("192.168.1.5", 5353)) + drop_v4 = _make_wrapped(("10.0.0.9", 5353)) + keep_v6 = _make_wrapped(("fe80::1", 5353, 0, 2), is_ipv6=True, multicast_index=2) + drop_v6 = _make_wrapped(("fe80::1", 5353, 0, 3), is_ipv6=True, multicast_index=3) + engine.senders = [keep_v4, drop_v4, keep_v6, drop_v6] + + # Keep 192.168.1.5 and fe80::1%2; drop 10.0.0.9 and fe80::1%3; add 192.168.1.9. + desired = ["192.168.1.5", "192.168.1.9", (("fe80::1", 0, 2), 2)] + added_sockets: list = [] + + def _fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: + added_sockets.append(sock) + wrapped = _make_wrapped(("added", 0)) + engine.senders.append(wrapped) + return wrapped + + with ( + patch.object(_engine, "normalize_interface_choice", return_value=desired), + patch.object(_engine, "add_multicast_member", return_value=True), + patch.object(_engine, "new_respond_socket", return_value=Mock()), + patch.object(_engine, "drop_multicast_member") as mock_drop, + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=_fake_wrap)), + ): + added = await engine.async_update_interfaces(["unused"], IPVersion.All, False) + + assert added is True + # Unchanged senders are the same objects; gone ones are removed. + assert keep_v4 in engine.senders + assert keep_v6 in engine.senders + assert drop_v4 not in engine.senders + assert drop_v6 not in engine.senders + # Exactly one brand-new interface was added. + assert len(added_sockets) == 1 + # Each gone interface left its group with its own representation; the + # scope-3 v6 is dropped while the scope-2 v6 with the same address is kept. + dropped = {call.args[1] for call in mock_drop.call_args_list} + assert dropped == {"10.0.0.9", (("fe80::1", 0, 0), 3)} + + +@pytest.mark.asyncio +async def test_update_interfaces_reannounces_services_on_add(aiozc_loopback: AsyncZeroconf) -> None: + """Existing registrations are re-announced when a new sender appears.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + info = ServiceInfo( + "_test._tcp.local.", + "Test._test._tcp.local.", + addresses=[b"\x7f\x00\x00\x01"], + port=80, + server="test.local.", + ) + await aiozc_loopback.async_register_service(info) + # Drop the sender so the next rescan genuinely adds one back. + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + + with patch.object(zc, "_async_broadcast_service", new_callable=AsyncMock) as mock_broadcast: + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + await asyncio.sleep(0) + + assert mock_broadcast.call_count == 1 + assert mock_broadcast.call_args.args[0] is info + + +@pytest.mark.asyncio +async def test_update_interfaces_noop_does_not_reannounce(aiozc_loopback: AsyncZeroconf) -> None: + """An unchanged interface set neither touches sockets nor re-announces.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + info = ServiceInfo( + "_test._tcp.local.", + "Test._test._tcp.local.", + addresses=[b"\x7f\x00\x00\x01"], + port=80, + server="test.local.", + ) + await aiozc_loopback.async_register_service(info) + before = (len(engine.senders), len(engine.readers), len(engine.protocols)) + + with patch.object(zc, "_async_broadcast_service", new_callable=AsyncMock) as mock_broadcast: + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + await asyncio.sleep(0) + + mock_broadcast.assert_not_called() + assert (len(engine.senders), len(engine.readers), len(engine.protocols)) == before + + +@pytest.mark.asyncio +async def test_update_interfaces_logs_reannounce_errors( + aiozc_loopback: AsyncZeroconf, caplog: pytest.LogCaptureFixture +) -> None: + """A re-announce failure is logged and does not propagate out of the rescan.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + info = ServiceInfo( + "_test._tcp.local.", + "Test._test._tcp.local.", + addresses=[b"\x7f\x00\x00\x01"], + port=80, + server="test.local.", + ) + await aiozc_loopback.async_register_service(info) + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + + with ( + patch.object(zc, "_async_broadcast_service", new_callable=AsyncMock, side_effect=ValueError("boom")), + caplog.at_level(logging.WARNING), + ): + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + await asyncio.sleep(0) + + assert "Error re-announcing service after interface update" in caplog.text + + +@pytest.mark.asyncio +async def test_update_interfaces_add_membership_failure(aiozc_loopback: AsyncZeroconf) -> None: + """A failed multicast join skips adding the responder socket.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + + with patch.object(_engine, "add_multicast_member", return_value=False): + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + assert engine.senders == [] + + +@pytest.mark.asyncio +async def test_update_interfaces_respond_socket_none_rolls_back(aiozc_loopback: AsyncZeroconf) -> None: + """A None responder socket drops the membership just joined.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + + with ( + patch.object(_engine, "add_multicast_member", return_value=True), + patch.object(_engine, "new_respond_socket", return_value=None), + patch.object(_engine, "drop_multicast_member") as mock_drop, + ): + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + assert engine.senders == [] + mock_drop.assert_called_once() + + +@pytest.mark.asyncio +async def test_update_interfaces_keeps_dual_use_listen_socket() -> None: + """The Default single-family dual-use socket is never torn down on rescan.""" + aiozc = AsyncZeroconf(interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only) + try: + zc = aiozc.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + assert engine._listen_transport is not None + sender_count = len(engine.senders) + await aiozc.async_update_interfaces([]) + await asyncio.sleep(0) + assert len(engine.senders) == sender_count + finally: + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_update_interfaces_unicast_has_no_listen_socket() -> None: + """In unicast mode there is no listen socket, so membership ops are skipped.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"], unicast=True) + try: + zc = aiozc.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + assert engine._listen_transport is None + await aiozc.async_update_interfaces([]) + await asyncio.sleep(0) + assert engine.senders == [] + # A None responder socket has no membership to roll back without a listen socket. + with ( + patch.object(_engine, "new_respond_socket", return_value=None), + patch.object(_engine, "drop_multicast_member") as mock_drop, + ): + await aiozc.async_update_interfaces(["127.0.0.1"]) + assert engine.senders == [] + mock_drop.assert_not_called() + await aiozc.async_update_interfaces(["127.0.0.1"]) + await asyncio.sleep(0) + assert len(engine.senders) == 1 + finally: + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_update_interfaces_serializes_concurrent_calls(aiozc_loopback: AsyncZeroconf) -> None: + """Overlapping rescans are serialized so an interface is not added twice.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + assert engine.senders == [] + + await asyncio.gather( + aiozc_loopback.async_update_interfaces(["127.0.0.1"]), + aiozc_loopback.async_update_interfaces(["127.0.0.1"]), + ) + await asyncio.sleep(0) + assert len(engine.senders) == 1 + + +@pytest.mark.asyncio +async def test_update_interfaces_keeps_config_on_reconcile_failure(aiozc_loopback: AsyncZeroconf) -> None: + """A failed engine reconcile leaves the retained interface config unchanged.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + original_interfaces = zc._interfaces + original_ip_version = zc._ip_version + + with ( + patch.object(_engine.AsyncEngine, "async_update_interfaces", new=AsyncMock(side_effect=OSError)), + pytest.raises(OSError), + ): + await aiozc_loopback.async_update_interfaces(["10.0.0.1"], ip_version=IPVersion.All) + + assert zc._interfaces == original_interfaces + assert zc._ip_version == original_ip_version + + +def test_sync_update_interfaces(zc_loopback: Zeroconf) -> None: + """The sync wrapper drives a rescan through the loop without changing a stable set.""" + engine = zc_loopback.engine + sender_count = len(engine.senders) + zc_loopback.update_interfaces(["127.0.0.1"]) + assert len(engine.senders) == sender_count diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 311e95e6..dad2cb27 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -287,6 +287,46 @@ def test_add_multicast_member(caplog: pytest.LogCaptureFixture) -> None: assert "net.ipv4.igmp_max_memberships" not in caplog.text +def test_drop_multicast_member() -> None: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + interface = "127.0.0.1" + + # No error should return True + with patch("socket.socket.setsockopt"): + assert netutils.drop_multicast_member(sock, interface) is True + + # IPv6 leave should return True + with patch("socket.socket.setsockopt"): + assert netutils.drop_multicast_member(sock, (("ff02::fb", 0, 0), 1)) is True # type: ignore[arg-type] + + # Benign errnos when the interface is already gone should return False + for benign in (errno.EADDRNOTAVAIL, errno.EINVAL, errno.ENODEV, errno.ENOPROTOOPT): + with patch("socket.socket.setsockopt", side_effect=OSError(benign, None)): + assert netutils.drop_multicast_member(sock, interface) is False + + # EPERM should always raise + with ( + pytest.raises(OSError), + patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)), + ): + netutils.drop_multicast_member(sock, interface) + + # No IPv6 support should return False for IPv6 + with patch("socket.inet_pton", side_effect=OSError()): + assert netutils.drop_multicast_member(sock, (("ff02::fb", 0, 0), 1)) is False # type: ignore[arg-type] + + +def test_drop_multicast_member_wsaeinval(monkeypatch: pytest.MonkeyPatch) -> None: + """On Windows, WSAEINVAL when leaving the group is treated as benign.""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(errno, "WSAEINVAL", 10022, raising=False) + with ( + socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock, + patch("socket.socket.setsockopt", side_effect=OSError(10022, None)), + ): + assert netutils.drop_multicast_member(sock, "127.0.0.1") is False + + def test_bind_raises_skips_address(): """Test bind failing in new_socket returns None on EADDRNOTAVAIL.""" err = errno.EADDRNOTAVAIL From e3afb881af7ee1c35cf845228b085f960da38d30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 22:07:28 -0500 Subject: [PATCH 02/23] address review: guard Default kind-switch, unlock re-announce, copy interfaces, apple_p2p guard --- src/zeroconf/_core.py | 40 ++++++++++++++++++++------------- src/zeroconf/_engine.py | 16 +++++++++++++ tests/test_interface_update.py | 41 ++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 88520a15..309c35ce 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -201,7 +201,8 @@ def __init__( self._use_asyncio = use_asyncio # Retained so async_update_interfaces can re-run create_sockets / # normalize_interface_choice against the live interface set later. - self._interfaces = interfaces + # Copy a mutable list so later caller mutation can't change it. + self._interfaces = list(interfaces) if isinstance(interfaces, list) else interfaces self._ip_version = ip_version self._apple_p2p = apple_p2p listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) @@ -456,26 +457,33 @@ async def async_update_interfaces( interfaces = self._interfaces if interfaces is None else interfaces ip_version = self._ip_version if ip_version is None else ip_version apple_p2p = self._apple_p2p if apple_p2p is None else apple_p2p + if apple_p2p and sys.platform != "darwin": + raise RuntimeError("Option `apple_p2p` is not supported on non-Apple platforms.") await self.async_wait_for_start() + # Only the reconcile mutates the sender set, so hold the lock for that + # alone; the multi-second re-announce runs unlocked so a bursty + # adapter-change source isn't blocked behind it. async with self._interface_update_lock: added = await self.engine.async_update_interfaces(interfaces, ip_version, apple_p2p) - self._interfaces = interfaces + # Copy a mutable list so later caller mutation can't change the + # retained configuration. + self._interfaces = list(interfaces) if isinstance(interfaces, list) else interfaces self._ip_version = ip_version self._apple_p2p = apple_p2p - if not added: - return - # Re-announce every registration; one broadcast failing must not - # mask the rest, so collect exceptions and log them individually. - results = await asyncio.gather( - *[ - self._async_broadcast_service(info, _REGISTER_TIME, None) - for info in self.registry.async_get_service_infos() - ], - return_exceptions=True, - ) - for result in results: - if isinstance(result, Exception): - log.warning("Error re-announcing service after interface update: %s", result) + if not added: + return + # Re-announce every registration; one broadcast failing must not mask + # the rest, so collect exceptions and log them individually. + results = await asyncio.gather( + *[ + self._async_broadcast_service(info, _REGISTER_TIME, None) + for info in self.registry.async_get_service_infos() + ], + return_exceptions=True, + ) + for result in results: + if isinstance(result, Exception): + log.warning("Error re-announcing service after interface update: %s", result) async def async_get_service_info( self, diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 13a3a550..1fac9758 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -181,6 +181,22 @@ async def async_update_interfaces( listen_transport = self._listen_transport listen_socket = listen_transport.sock if listen_transport is not None else None + # A Default single-family instance shares the listen socket as its + # only sender; adding per-interface senders alongside it would double + # every announcement. Switching interface kind at runtime is not + # supported, so raise (before any state changes) rather than + # double-send. The no-arg refresh of a Default instance keys to the + # listen socket and never reaches here. + if ( + listen_transport is not None + and any(wrapped.transport is listen_transport.transport for wrapped in self.senders) + and any(key != listen_transport.interface_key for key in desired) + ): + raise RuntimeError( + "Cannot change interfaces on a Default single-family Zeroconf instance; " + "recreate it to use an explicit interface set" + ) + for bind_address, wrapped in current.items(): if bind_address in desired: continue diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 222cfb66..855602aa 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -341,6 +341,47 @@ async def test_update_interfaces_keeps_dual_use_listen_socket() -> None: await aiozc.async_close() +@pytest.mark.asyncio +async def test_update_interfaces_default_explicit_list_raises() -> None: + """An explicit set on a Default single-family instance raises before any state changes.""" + aiozc = AsyncZeroconf(interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only) + try: + zc = aiozc.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + sender_count = len(engine.senders) + original = zc._interfaces + with pytest.raises(RuntimeError, match="Default single-family"): + await aiozc.async_update_interfaces(["127.0.0.1"]) + # No per-interface sender added, retained config unchanged. + assert len(engine.senders) == sender_count + assert zc._interfaces == original + finally: + await aiozc.async_close() + + +@pytest.mark.asyncio +async def test_update_interfaces_apple_p2p_non_darwin_raises(aiozc_loopback: AsyncZeroconf) -> None: + """apple_p2p=True on a non-Apple platform raises, matching __init__.""" + await aiozc_loopback.zeroconf.async_wait_for_start() + with ( + patch("zeroconf._core.sys.platform", "linux"), + pytest.raises(RuntimeError, match="apple_p2p"), + ): + await aiozc_loopback.async_update_interfaces(apple_p2p=True) + + +@pytest.mark.asyncio +async def test_update_interfaces_copies_interface_list(aiozc_loopback: AsyncZeroconf) -> None: + """A mutable interfaces list is copied so later mutation doesn't change retained config.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + ifaces = ["127.0.0.1"] + await aiozc_loopback.async_update_interfaces(ifaces) + ifaces.append("10.0.0.1") + assert zc._interfaces == ["127.0.0.1"] + + @pytest.mark.asyncio async def test_update_interfaces_unicast_has_no_listen_socket() -> None: """In unicast mode there is no listen socket, so membership ops are skipped.""" From 60eddffaae137801db91c2a5ae3c65cce58531ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 22:30:27 -0500 Subject: [PATCH 03/23] harden: narrow getsockopt mask logging, roll back per-interface add on wrap failure --- src/zeroconf/_engine.py | 10 +++++- src/zeroconf/_transport.py | 11 +++++- tests/test_interface_update.py | 64 ++++++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 1fac9758..60c0c7e1 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -238,7 +238,15 @@ async def _async_add_interface( drop_multicast_member(listen_socket, interface) self.zc.log_warning_once(f"Interface {interface!r} not added: no responder socket") return False - await self._async_wrap_socket(respond_socket, is_sender=True) + try: + await self._async_wrap_socket(respond_socket, is_sender=True) + except Exception: + # Endpoint creation failed after the join/socket succeeded; roll + # this interface back so it leaves no dangling group membership. + respond_socket.close() + if listen_socket is not None: + drop_multicast_member(listen_socket, interface) + raise return True def _async_close_sender(self, wrapped: _WrappedTransport, listen_socket: socket.socket | None) -> None: diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index 8a68c44c..a2d9ccbe 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -24,8 +24,11 @@ import asyncio import socket +import sys from typing import cast +from ._logger import log + def _strip_zone(address: str) -> str: """Drop a ``%zone`` suffix from an IPv6 address string.""" @@ -106,7 +109,13 @@ def make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTran # the leave falls back to the default interface as it did before. try: multicast_index = sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF) - except OSError: + except OSError as exc: + # Windows rejects reading IPV6_MULTICAST_IF (WSAEINVAL); fall back + # to the default index. On other platforms this read does not + # fail, so log an unexpected error rather than silently masking it + # into a wrong-interface group leave. + if sys.platform != "win32": + log.debug("Unexpected error reading IPV6_MULTICAST_IF: %s", exc) multicast_index = 0 return _WrappedTransport( transport=transport, diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 855602aa..a8dd56f4 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -77,7 +77,7 @@ def test_make_wrapped_transport_reads_v6_multicast_index() -> None: def test_make_wrapped_transport_unreadable_multicast_index() -> None: - """A socket that rejects reading IPV6_MULTICAST_IF (Windows) falls back to index 0.""" + """A socket that rejects reading IPV6_MULTICAST_IF falls back to index 0.""" sock = Mock() sock.family = socket.AF_INET6 sock.fileno.return_value = 0 @@ -85,7 +85,12 @@ def test_make_wrapped_transport_unreadable_multicast_index() -> None: sock.getsockopt.side_effect = OSError transport = Mock() transport.get_extra_info.return_value = sock - assert make_wrapped_transport(transport).multicast_index == 0 + # Windows: expected (WSAEINVAL), silent fallback. + with patch("zeroconf._transport.sys.platform", "win32"): + assert make_wrapped_transport(transport).multicast_index == 0 + # Other platforms: unexpected, fall back but log it. + with patch("zeroconf._transport.sys.platform", "linux"): + assert make_wrapped_transport(transport).multicast_index == 0 @pytest.mark.asyncio @@ -324,6 +329,61 @@ async def test_update_interfaces_respond_socket_none_rolls_back(aiozc_loopback: mock_drop.assert_called_once() +@pytest.mark.asyncio +async def test_update_interfaces_rolls_back_membership_on_wrap_failure( + aiozc_loopback: AsyncZeroconf, +) -> None: + """If endpoint creation raises, the interface's join and socket are rolled back.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + assert zc._interfaces == [] + + fake_socket = Mock() + with ( + patch.object(_engine, "add_multicast_member", return_value=True), + patch.object(_engine, "new_respond_socket", return_value=fake_socket), + patch.object(_engine, "drop_multicast_member") as mock_drop, + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))), + pytest.raises(OSError), + ): + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + + # The just-joined membership was dropped, the socket closed, and the + # failed reconcile left the retained config unchanged. + mock_drop.assert_called_once() + fake_socket.close.assert_called_once() + assert zc._interfaces == [] + + +@pytest.mark.asyncio +async def test_update_interfaces_rollback_unicast_no_membership_drop() -> None: + """A wrap failure in unicast mode closes the socket but has no membership to drop.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"], unicast=True) + try: + zc = aiozc.zeroconf + await zc.async_wait_for_start() + await aiozc.async_update_interfaces([]) + await asyncio.sleep(0) + + fake_socket = Mock() + with ( + patch.object(_engine, "new_respond_socket", return_value=fake_socket), + patch.object(_engine, "drop_multicast_member") as mock_drop, + patch.object( + _engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom")) + ), + pytest.raises(OSError), + ): + await aiozc.async_update_interfaces(["127.0.0.1"]) + + fake_socket.close.assert_called_once() + mock_drop.assert_not_called() + finally: + await aiozc.async_close() + + @pytest.mark.asyncio async def test_update_interfaces_keeps_dual_use_listen_socket() -> None: """The Default single-family dual-use socket is never torn down on rescan.""" From 6e5d3146cd01970ff177ee44077c444985440439 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 22:40:37 -0500 Subject: [PATCH 04/23] test: drive Default/unicast cases at the engine level to avoid real-multicast contention --- tests/test_interface_update.py | 90 ++++++++++++++-------------------- 1 file changed, 37 insertions(+), 53 deletions(-) diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index a8dd56f4..8836389d 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -13,7 +13,6 @@ from zeroconf import IPVersion, ServiceInfo, Zeroconf, _engine from zeroconf._engine import _interface_key from zeroconf._transport import _strip_zone, _WrappedTransport, make_wrapped_transport -from zeroconf._utils.net import InterfaceChoice from zeroconf.asyncio import AsyncZeroconf @@ -358,66 +357,51 @@ async def test_update_interfaces_rolls_back_membership_on_wrap_failure( @pytest.mark.asyncio -async def test_update_interfaces_rollback_unicast_no_membership_drop() -> None: - """A wrap failure in unicast mode closes the socket but has no membership to drop.""" - aiozc = AsyncZeroconf(interfaces=["127.0.0.1"], unicast=True) - try: - zc = aiozc.zeroconf - await zc.async_wait_for_start() - await aiozc.async_update_interfaces([]) - await asyncio.sleep(0) +async def test_add_interface_rollback_without_listen_socket(aiozc_loopback: AsyncZeroconf) -> None: + """A wrap failure with no listen socket (unicast) closes the socket and drops no membership.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() - fake_socket = Mock() - with ( - patch.object(_engine, "new_respond_socket", return_value=fake_socket), - patch.object(_engine, "drop_multicast_member") as mock_drop, - patch.object( - _engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom")) - ), - pytest.raises(OSError), - ): - await aiozc.async_update_interfaces(["127.0.0.1"]) + fake_socket = Mock() + with ( + patch.object(_engine, "new_respond_socket", return_value=fake_socket), + patch.object(_engine, "drop_multicast_member") as mock_drop, + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))), + pytest.raises(OSError), + ): + await engine._async_add_interface("127.0.0.1", None, False) - fake_socket.close.assert_called_once() - mock_drop.assert_not_called() - finally: - await aiozc.async_close() + fake_socket.close.assert_called_once() + mock_drop.assert_not_called() @pytest.mark.asyncio -async def test_update_interfaces_keeps_dual_use_listen_socket() -> None: - """The Default single-family dual-use socket is never torn down on rescan.""" - aiozc = AsyncZeroconf(interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only) - try: - zc = aiozc.zeroconf - await zc.async_wait_for_start() - engine = zc.engine - assert engine._listen_transport is not None - sender_count = len(engine.senders) - await aiozc.async_update_interfaces([]) - await asyncio.sleep(0) - assert len(engine.senders) == sender_count - finally: - await aiozc.async_close() +async def test_update_interfaces_keeps_dual_use_listen_socket(aiozc_loopback: AsyncZeroconf) -> None: + """A dual-use sender (the listen socket itself) is never torn down on rescan.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + listen = engine._listen_transport + assert listen is not None + # Simulate a Default single-family instance: the listen socket is the sole sender. + engine.senders = [listen] + await engine.async_update_interfaces([], IPVersion.V4Only, False) + assert engine.senders == [listen] @pytest.mark.asyncio -async def test_update_interfaces_default_explicit_list_raises() -> None: - """An explicit set on a Default single-family instance raises before any state changes.""" - aiozc = AsyncZeroconf(interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only) - try: - zc = aiozc.zeroconf - await zc.async_wait_for_start() - engine = zc.engine - sender_count = len(engine.senders) - original = zc._interfaces - with pytest.raises(RuntimeError, match="Default single-family"): - await aiozc.async_update_interfaces(["127.0.0.1"]) - # No per-interface sender added, retained config unchanged. - assert len(engine.senders) == sender_count - assert zc._interfaces == original - finally: - await aiozc.async_close() +async def test_update_interfaces_default_explicit_list_raises(aiozc_loopback: AsyncZeroconf) -> None: + """An explicit set on a dual-use instance raises before any state change.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + listen = engine._listen_transport + assert listen is not None + engine.senders = [listen] + with ( + patch.object(_engine, "normalize_interface_choice", return_value=["192.168.1.5"]), + pytest.raises(RuntimeError, match="Default single-family"), + ): + await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False) + assert engine.senders == [listen] @pytest.mark.asyncio From e05adddebfaa556f25da0ed4326565d035afe7b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 22:52:42 -0500 Subject: [PATCH 05/23] harden: cancel removed interface listener TC timers; warn on unexpected IPV6_MULTICAST_IF read --- src/zeroconf/_engine.py | 12 +++++++--- src/zeroconf/_listener.py | 12 ++++++++++ src/zeroconf/_transport.py | 6 ++--- tests/test_interface_update.py | 41 +++++++++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 60c0c7e1..5d403295 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -252,9 +252,15 @@ async def _async_add_interface( def _async_close_sender(self, wrapped: _WrappedTransport, listen_socket: socket.socket | None) -> None: """Drop a per-interface sender's wrappers/protocol and close its transport.""" transport = wrapped.transport - self.protocols = [ - p for p in self.protocols if p.transport is None or p.transport.transport is not transport - ] + kept_protocols = [] + for protocol in self.protocols: + if protocol.transport is not None and protocol.transport.transport is transport: + # Cancel any pending TC-reassembly timers so one can't fire a + # response against the transport we're about to close. + protocol.cancel_pending_timers() + else: + kept_protocols.append(protocol) + self.protocols = kept_protocols self.readers = [w for w in self.readers if w.transport is not transport] self.senders = [w for w in self.senders if w.transport is not transport] if listen_socket is not None: diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 7be2a828..2459b8ea 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -309,6 +309,18 @@ def _cancel_any_timers_for_addr(self, addr: _str) -> None: if addr in self._timers: self._timers.pop(addr).cancel() + def cancel_pending_timers(self) -> None: + """Cancel all pending TC-reassembly timers and drop deferred state. + + Called when this listener's transport is removed so a timer cannot + fire a response against an already-closed transport. + """ + for timer in self._timers.values(): + timer.cancel() + self._timers.clear() + self._deferred.clear() + self._deferred_deadlines.clear() + def _evict_oldest_deferred(self) -> None: """Discard the oldest deferred addr's reassembly state. diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index a2d9ccbe..66669650 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -112,10 +112,10 @@ def make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTran except OSError as exc: # Windows rejects reading IPV6_MULTICAST_IF (WSAEINVAL); fall back # to the default index. On other platforms this read does not - # fail, so log an unexpected error rather than silently masking it - # into a wrong-interface group leave. + # fail, so surface an unexpected error at warning rather than + # silently masking it into a wrong-interface group leave. if sys.platform != "win32": - log.debug("Unexpected error reading IPV6_MULTICAST_IF: %s", exc) + log.warning("Unexpected error reading IPV6_MULTICAST_IF: %s", exc) multicast_index = 0 return _WrappedTransport( transport=transport, diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 8836389d..7587afe9 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -10,7 +10,7 @@ import pytest -from zeroconf import IPVersion, ServiceInfo, Zeroconf, _engine +from zeroconf import IPVersion, ServiceInfo, Zeroconf, _engine, _listener from zeroconf._engine import _interface_key from zeroconf._transport import _strip_zone, _WrappedTransport, make_wrapped_transport from zeroconf.asyncio import AsyncZeroconf @@ -168,6 +168,45 @@ async def test_update_interfaces_keeps_unchanged_sender_untouched(aiozc_loopback gone_transport.close.assert_called_once() +@pytest.mark.asyncio +async def test_update_interfaces_cancels_removed_listener_timers(aiozc_loopback: AsyncZeroconf) -> None: + """Removing an interface cancels its listener's pending TC-reassembly timers.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + sender = engine.senders[0] + protocol = next( + p for p in engine.protocols if p.transport is not None and p.transport.transport is sender.transport + ) + timer = Mock() + protocol._timers["1.2.3.4"] = timer + protocol._deferred["1.2.3.4"] = [] + protocol._deferred_deadlines["1.2.3.4"] = 0.0 + + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + + timer.cancel.assert_called_once() + assert protocol._timers == {} + assert protocol._deferred == {} + + +@pytest.mark.asyncio +async def test_close_sender_keeps_protocol_without_transport(aiozc_loopback: AsyncZeroconf) -> None: + """A protocol that never bound a transport is left in place when a sender is closed.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + sender = engine.senders[0] + orphan = _listener.AsyncListener(aiozc_loopback.zeroconf) + assert orphan.transport is None + engine.protocols.append(orphan) + + with patch.object(_engine, "drop_multicast_member"): + engine._async_close_sender(sender, None) + + assert orphan in engine.protocols + + @pytest.mark.asyncio async def test_update_interfaces_reconciles_mixed_set(aiozc_loopback: AsyncZeroconf) -> None: """One rescan keeps unchanged, drops gone, adds new across v4 and link-local v6. From 3dae38b7d4bc10d62a5b13b8e05371d6a8e54903 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 23:20:21 -0500 Subject: [PATCH 06/23] feat: rebuild listen socket on incompatible family change instead of guarding --- src/zeroconf/_engine.py | 76 +++++++++++++++++-- src/zeroconf/_transport.py | 15 ++-- tests/test_interface_update.py | 131 +++++++++++++++++++++++++++++++-- 3 files changed, 201 insertions(+), 21 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 5d403295..5a351d2e 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -36,6 +36,7 @@ add_multicast_member, drop_multicast_member, new_respond_socket, + new_socket, normalize_interface_choice, ) from ._utils.time import current_time_millis @@ -63,6 +64,20 @@ def _interface_key(interface: str | tuple[tuple[str, int, int], int]) -> tuple[s return (interface, 0) +def _listen_socket_supports( + listen_socket: socket.socket, interface: str | tuple[tuple[str, int, int], int] +) -> bool: + """Whether the fixed-family listen socket can join this interface's group.""" + if isinstance(interface, tuple): + # An IPv6 interface can only be joined on an AF_INET6 socket. + return listen_socket.family == socket.AF_INET6 + if listen_socket.family != socket.AF_INET6: + # An IPv4 interface on an AF_INET socket. + return True + # An IPv4 interface on an AF_INET6 socket: only when it is dual-stack. + return not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) + + class AsyncEngine: """An engine wraps sockets in the event loop.""" @@ -197,6 +212,17 @@ async def async_update_interfaces( "recreate it to use an explicit interface set" ) + # The listen socket's family is fixed at construction. If a desired + # interface cannot be joined on it (e.g. an IPv6 interface added to an + # IPv4 instance), rebuild the listen socket for the new family before + # reconciling senders, otherwise the current senders would be torn down + # with no replacements bound. + if listen_socket is not None and any( + not _listen_socket_supports(listen_socket, interface) for interface in desired.values() + ): + listen_socket = await self._async_rebuild_listen_socket(ip_version, apple_p2p, desired, current) + listen_transport = self._listen_transport + for bind_address, wrapped in current.items(): if bind_address in desired: continue @@ -249,9 +275,8 @@ async def _async_add_interface( raise return True - def _async_close_sender(self, wrapped: _WrappedTransport, listen_socket: socket.socket | None) -> None: - """Drop a per-interface sender's wrappers/protocol and close its transport.""" - transport = wrapped.transport + def _async_remove_transport(self, transport: asyncio.DatagramTransport) -> None: + """Drop a transport's protocol/reader/sender wrappers, cancelling its timers.""" kept_protocols = [] for protocol in self.protocols: if protocol.transport is not None and protocol.transport.transport is transport: @@ -263,9 +288,48 @@ def _async_close_sender(self, wrapped: _WrappedTransport, listen_socket: socket. self.protocols = kept_protocols self.readers = [w for w in self.readers if w.transport is not transport] self.senders = [w for w in self.senders if w.transport is not transport] - if listen_socket is not None: - drop_multicast_member(listen_socket, wrapped.multicast_interface) - transport.close() + + def _async_close_sender(self, wrapped: _WrappedTransport, listen_socket: socket.socket | None) -> None: + """Drop a per-interface sender's wrappers/protocol and close its transport.""" + transport = wrapped.transport + self._async_remove_transport(transport) + try: + if listen_socket is not None: + drop_multicast_member(listen_socket, wrapped.multicast_interface) + finally: + # Release the socket even if a non-benign leave (e.g. EPERM) raises. + transport.close() + + async def _async_rebuild_listen_socket( + self, + ip_version: IPVersion, + apple_p2p: bool, + desired: dict[tuple[str, int], str | tuple[tuple[str, int, int], int]], + current: dict[tuple[str, int], _WrappedTransport], + ) -> socket.socket: + """Replace the listen socket with one whose family covers the desired set. + + The listen socket's family is otherwise fixed at construction; this + lets an instance start receiving a newly added address family. Only + called when a desired interface cannot be joined on the current listen + socket. Interfaces that are staying are re-joined on the new socket, + and the old socket is closed (releasing its memberships). + """ + new_listen = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p) + if new_listen is None: + raise RuntimeError("Failed to create a listen socket for the requested interface family") + for bind_address, interface in desired.items(): + if bind_address in current: + add_multicast_member(new_listen, interface) + # A rebuild is only entered with a live listen socket, so the old + # transport is always present. + old_listen_transport = self._listen_transport + assert old_listen_transport is not None + self._listen_transport = await self._async_wrap_socket(new_listen, is_sender=False) + old_transport = old_listen_transport.transport + self._async_remove_transport(old_transport) + old_transport.close() + return new_listen def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index 66669650..ac106356 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -27,8 +27,6 @@ import sys from typing import cast -from ._logger import log - def _strip_zone(address: str) -> str: """Drop a ``%zone`` suffix from an IPv6 address string.""" @@ -109,14 +107,13 @@ def make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTran # the leave falls back to the default interface as it did before. try: multicast_index = sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF) - except OSError as exc: - # Windows rejects reading IPV6_MULTICAST_IF (WSAEINVAL); fall back - # to the default index. On other platforms this read does not - # fail, so surface an unexpected error at warning rather than - # silently masking it into a wrong-interface group leave. + except OSError: + # Windows rejects reading IPV6_MULTICAST_IF (WSAEINVAL); the + # default index 0 set above is kept there. On other platforms this + # read does not fail, so re-raise rather than mask a genuine error + # into a wrong-interface group leave. if sys.platform != "win32": - log.warning("Unexpected error reading IPV6_MULTICAST_IF: %s", exc) - multicast_index = 0 + raise return _WrappedTransport( transport=transport, is_ipv6=is_ipv6, diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 7587afe9..57ba792f 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -11,7 +11,7 @@ import pytest from zeroconf import IPVersion, ServiceInfo, Zeroconf, _engine, _listener -from zeroconf._engine import _interface_key +from zeroconf._engine import _interface_key, _listen_socket_supports from zeroconf._transport import _strip_zone, _WrappedTransport, make_wrapped_transport from zeroconf.asyncio import AsyncZeroconf @@ -84,12 +84,30 @@ def test_make_wrapped_transport_unreadable_multicast_index() -> None: sock.getsockopt.side_effect = OSError transport = Mock() transport.get_extra_info.return_value = sock - # Windows: expected (WSAEINVAL), silent fallback. + # Windows: expected (WSAEINVAL), silent fallback to the default index. with patch("zeroconf._transport.sys.platform", "win32"): assert make_wrapped_transport(transport).multicast_index == 0 - # Other platforms: unexpected, fall back but log it. - with patch("zeroconf._transport.sys.platform", "linux"): - assert make_wrapped_transport(transport).multicast_index == 0 + # Other platforms: the read does not fail there, so an error is re-raised. + with patch("zeroconf._transport.sys.platform", "linux"), pytest.raises(OSError): + make_wrapped_transport(transport) + + +def test_listen_socket_supports_family() -> None: + """A desired interface is only supported by a listen socket of a compatible family.""" + v4_sock = Mock() + v4_sock.family = socket.AF_INET + v6_sock = Mock() + v6_sock.family = socket.AF_INET6 + v6_interface = (("fe80::1", 0, 0), 1) + + assert _listen_socket_supports(v4_sock, "1.2.3.4") is True + assert _listen_socket_supports(v4_sock, v6_interface) is False + assert _listen_socket_supports(v6_sock, v6_interface) is True + # IPv4 on an AF_INET6 socket depends on whether it is dual-stack. + v6_sock.getsockopt.return_value = 0 # IPV6_V6ONLY off -> dual-stack + assert _listen_socket_supports(v6_sock, "1.2.3.4") is True + v6_sock.getsockopt.return_value = 1 # IPV6_V6ONLY on -> v6-only + assert _listen_socket_supports(v6_sock, "1.2.3.4") is False @pytest.mark.asyncio @@ -236,6 +254,9 @@ def _fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: with ( patch.object(_engine, "normalize_interface_choice", return_value=desired), + # This test exercises the diff over a contrived sender set, not the + # listen-socket rebuild, so treat every family as supported. + patch.object(_engine, "_listen_socket_supports", return_value=True), patch.object(_engine, "add_multicast_member", return_value=True), patch.object(_engine, "new_respond_socket", return_value=Mock()), patch.object(_engine, "drop_multicast_member") as mock_drop, @@ -429,7 +450,7 @@ async def test_update_interfaces_keeps_dual_use_listen_socket(aiozc_loopback: As @pytest.mark.asyncio async def test_update_interfaces_default_explicit_list_raises(aiozc_loopback: AsyncZeroconf) -> None: - """An explicit set on a dual-use instance raises before any state change.""" + """An explicit set on a dual-use instance raises before any state change or rebuild.""" engine = aiozc_loopback.zeroconf.engine await aiozc_loopback.zeroconf.async_wait_for_start() listen = engine._listen_transport @@ -437,10 +458,108 @@ async def test_update_interfaces_default_explicit_list_raises(aiozc_loopback: As engine.senders = [listen] with ( patch.object(_engine, "normalize_interface_choice", return_value=["192.168.1.5"]), + patch.object(_engine, "new_socket") as mock_new_socket, pytest.raises(RuntimeError, match="Default single-family"), ): await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False) assert engine.senders == [listen] + # The dual-use guard takes precedence; it never falls through to a rebuild. + mock_new_socket.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_interfaces_does_not_rebuild_when_family_supported( + aiozc_loopback: AsyncZeroconf, +) -> None: + """Same-family rescans (and All/dual-stack) never rebuild the listen socket.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + listen = zc.engine._listen_transport + with patch.object(_engine, "new_socket") as mock_new_socket: + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + await asyncio.sleep(0) + mock_new_socket.assert_not_called() + assert zc.engine._listen_transport is listen + + +@pytest.mark.asyncio +async def test_update_interfaces_rebuilds_listen_socket_for_new_family( + aiozc_loopback: AsyncZeroconf, +) -> None: + """Adding an interface of an unsupported family rebuilds the listen socket once.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + old_listen = engine._listen_transport + assert old_listen is not None + assert old_listen.sock.family == socket.AF_INET # V4Only loopback instance + old_underlying = old_listen.transport + + # Keep the existing IPv4 interface and add an IPv6 one (which the IPv4 + # listen socket can't join, forcing a rebuild). + v6 = (("fe80::1", 0, 0), 1) + new_listen_sock = Mock() + new_listen_sock.family = socket.AF_INET6 + + async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: + wrapped = _make_wrapped(("wrapped", 0), transport=Mock()) + (engine.senders if is_sender else engine.readers).append(wrapped) + return wrapped + + with ( + patch.object(_engine, "normalize_interface_choice", return_value=["127.0.0.1", v6]), + patch.object(_engine, "new_socket", return_value=new_listen_sock) as mock_new_socket, + patch.object(_engine, "add_multicast_member", return_value=True) as mock_add, + patch.object(_engine, "new_respond_socket", return_value=Mock()), + patch.object(_engine, "drop_multicast_member"), + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=fake_wrap)), + ): + added = await engine.async_update_interfaces(["unused"], IPVersion.All, False) + + # Rebuilt exactly once, the v6 sender added, the listen transport swapped, + mock_new_socket.assert_called_once() + assert added is True + assert engine._listen_transport is not old_listen + # the kept IPv4 interface was re-joined on the new listen socket, + assert any( + call.args[0] is new_listen_sock and call.args[1] == "127.0.0.1" for call in mock_add.call_args_list + ) + # and the old listen socket closed and removed (no duplicate left behind). + assert old_underlying.is_closing() + assert old_listen not in engine.readers + + +@pytest.mark.asyncio +async def test_update_interfaces_rebuild_failure_raises(aiozc_loopback: AsyncZeroconf) -> None: + """If the replacement listen socket can't be created, the rebuild raises.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + with ( + patch.object(_engine, "normalize_interface_choice", return_value=[(("fe80::1", 0, 0), 1)]), + patch.object(_engine, "new_socket", return_value=None), + pytest.raises(RuntimeError, match="listen socket"), + ): + await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) + + +@pytest.mark.asyncio +async def test_close_sender_closes_transport_when_drop_raises(aiozc_loopback: AsyncZeroconf) -> None: + """A non-benign group-leave error still releases the transport.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + gone_transport = Mock() + gone = _make_wrapped(("10.0.0.5", 5353), transport=gone_transport) + listen_socket = Mock() + + with ( + patch.object(_engine, "drop_multicast_member", side_effect=OSError("EPERM")), + pytest.raises(OSError), + ): + engine._async_close_sender(gone, listen_socket) + + gone_transport.close.assert_called_once() @pytest.mark.asyncio From d05d3e2278ca2be105f9da7952b6aa0d3d178741 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 23:32:27 -0500 Subject: [PATCH 07/23] fix: rebuild listen socket safely - derive family from desired set, clean up on wrap failure, defend v6only read --- src/zeroconf/_engine.py | 44 ++++++++++++---- tests/test_interface_update.py | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 5a351d2e..85032fbc 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -74,8 +74,13 @@ def _listen_socket_supports( if listen_socket.family != socket.AF_INET6: # An IPv4 interface on an AF_INET socket. return True - # An IPv4 interface on an AF_INET6 socket: only when it is dual-stack. - return not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) + # An IPv4 interface on an AF_INET6 socket: only when it is dual-stack. If + # the option can't be read (as Windows rejects some IPv6 getsockopts), + # assume supported so a read failure can't drive a rebuild loop. + try: + return not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) + except OSError: + return True class AsyncEngine: @@ -220,7 +225,7 @@ async def async_update_interfaces( if listen_socket is not None and any( not _listen_socket_supports(listen_socket, interface) for interface in desired.values() ): - listen_socket = await self._async_rebuild_listen_socket(ip_version, apple_p2p, desired, current) + listen_socket = await self._async_rebuild_listen_socket(apple_p2p, desired, current) listen_transport = self._listen_transport for bind_address, wrapped in current.items(): @@ -302,7 +307,6 @@ def _async_close_sender(self, wrapped: _WrappedTransport, listen_socket: socket. async def _async_rebuild_listen_socket( self, - ip_version: IPVersion, apple_p2p: bool, desired: dict[tuple[str, int], str | tuple[tuple[str, int, int], int]], current: dict[tuple[str, int], _WrappedTransport], @@ -312,20 +316,38 @@ async def _async_rebuild_listen_socket( The listen socket's family is otherwise fixed at construction; this lets an instance start receiving a newly added address family. Only called when a desired interface cannot be joined on the current listen - socket. Interfaces that are staying are re-joined on the new socket, + socket. The replacement family is derived from the desired set (not the + requested ip_version, which an explicit list can contradict) so it + always covers every desired interface and never needs an immediate + re-rebuild. Interfaces that are staying are re-joined on the new socket, and the old socket is closed (releasing its memberships). """ - new_listen = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p) + has_v6 = any(isinstance(interface, tuple) for interface in desired.values()) + has_v4 = any(not isinstance(interface, tuple) for interface in desired.values()) + if has_v4 and has_v6: + family_version = IPVersion.All + elif has_v6: + family_version = IPVersion.V6Only + else: + family_version = IPVersion.V4Only + new_listen = new_socket(bind_addr=("",), ip_version=family_version, apple_p2p=apple_p2p) if new_listen is None: - raise RuntimeError("Failed to create a listen socket for the requested interface family") - for bind_address, interface in desired.items(): - if bind_address in current: - add_multicast_member(new_listen, interface) + raise RuntimeError("Failed to create a listen socket for the new interface family") + try: + for bind_address, interface in desired.items(): + if bind_address in current: + add_multicast_member(new_listen, interface) + new_reader = await self._async_wrap_socket(new_listen, is_sender=False) + except Exception: + # Endpoint creation failed; close the unadopted socket (and its + # joins) rather than leak it, mirroring _async_add_interface. + new_listen.close() + raise # A rebuild is only entered with a live listen socket, so the old # transport is always present. old_listen_transport = self._listen_transport assert old_listen_transport is not None - self._listen_transport = await self._async_wrap_socket(new_listen, is_sender=False) + self._listen_transport = new_reader old_transport = old_listen_transport.transport self._async_remove_transport(old_transport) old_transport.close() diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 57ba792f..253eb4d5 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -108,6 +108,10 @@ def test_listen_socket_supports_family() -> None: assert _listen_socket_supports(v6_sock, "1.2.3.4") is True v6_sock.getsockopt.return_value = 1 # IPV6_V6ONLY on -> v6-only assert _listen_socket_supports(v6_sock, "1.2.3.4") is False + # An unreadable option (some platforms) is treated as supported so it + # can't drive a rebuild loop. + v6_sock.getsockopt.side_effect = OSError + assert _listen_socket_supports(v6_sock, "1.2.3.4") is True @pytest.mark.asyncio @@ -544,6 +548,93 @@ async def test_update_interfaces_rebuild_failure_raises(aiozc_loopback: AsyncZer await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) +@pytest.mark.asyncio +async def test_update_interfaces_rebuild_closes_socket_on_wrap_failure( + aiozc_loopback: AsyncZeroconf, +) -> None: + """If wrapping the new listen socket fails, it is closed rather than leaked.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + old_listen = engine._listen_transport + new_listen_sock = Mock() + new_listen_sock.family = socket.AF_INET6 + + with ( + patch.object(_engine, "normalize_interface_choice", return_value=[(("fe80::1", 0, 0), 1)]), + patch.object(_engine, "new_socket", return_value=new_listen_sock), + patch.object(_engine, "add_multicast_member", return_value=True), + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))), + pytest.raises(OSError), + ): + await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) + + # The unadopted socket was closed, and the old listen socket is untouched. + new_listen_sock.close.assert_called_once() + assert engine._listen_transport is old_listen + + +@pytest.mark.asyncio +async def test_update_interfaces_rebuild_family_matches_desired_set( + aiozc_loopback: AsyncZeroconf, +) -> None: + """The rebuilt listen socket's family is derived from the desired set, not ip_version.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + new_listen_sock = Mock() + new_listen_sock.family = socket.AF_INET + + async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: + wrapped = _make_wrapped(("wrapped", 0), transport=Mock()) + (engine.senders if is_sender else engine.readers).append(wrapped) + return wrapped + + with ( + patch.object(_engine, "normalize_interface_choice", return_value=["192.168.1.5"]), + patch.object(_engine, "_listen_socket_supports", return_value=False), # force a rebuild + patch.object(_engine, "new_socket", return_value=new_listen_sock) as mock_new_socket, + patch.object(_engine, "add_multicast_member", return_value=True), + patch.object(_engine, "new_respond_socket", return_value=Mock()), + patch.object(_engine, "drop_multicast_member"), + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=fake_wrap)), + ): + # ip_version says V6Only, but the desired set is all IPv4, so the + # rebuilt socket is IPv4 (covers the set; no immediate re-rebuild). + await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) + + mock_new_socket.assert_called_once() + assert mock_new_socket.call_args.kwargs["ip_version"] is IPVersion.V4Only + + +@pytest.mark.asyncio +async def test_update_interfaces_rebuilds_real_listen_socket(aiozc_loopback: AsyncZeroconf) -> None: + """End to end: a family change builds a real dual-stack listen socket and closes the old one.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + old_listen = engine._listen_transport + assert old_listen is not None + assert old_listen.sock.family == socket.AF_INET # V4Only loopback instance + old_underlying = old_listen.transport + + v6 = (("fe80::1", 0, 0), 1) + # Real new_socket + _async_wrap_socket run; only membership joins and the + # (unbindable) v6 responder are stubbed so no real multicast is exercised. + with ( + patch.object(_engine, "normalize_interface_choice", return_value=["127.0.0.1", v6]), + patch.object(_engine, "add_multicast_member", return_value=True), + patch.object(_engine, "new_respond_socket", return_value=None), + ): + await engine.async_update_interfaces(["unused"], IPVersion.All, False) + + new_listen = engine._listen_transport + assert new_listen is not None + assert new_listen is not old_listen + assert new_listen.sock.family == socket.AF_INET6 # rebuilt to a dual-stack socket + # The old listen socket was closed and removed; no duplicate remains. + assert old_underlying.is_closing() + assert old_listen not in engine.readers + assert sum(1 for r in engine.readers if r is new_listen) == 1 + + @pytest.mark.asyncio async def test_close_sender_closes_transport_when_drop_raises(aiozc_loopback: AsyncZeroconf) -> None: """A non-benign group-leave error still releases the transport.""" From 86771c6ca57a2a5bc04eeebb289c1d73803efd19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 23:37:52 -0500 Subject: [PATCH 08/23] refactor: drop redundant rebuild return value; trim duplicate rebuild test --- src/zeroconf/_engine.py | 6 +++--- tests/test_interface_update.py | 20 ++++---------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 85032fbc..ff937f81 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -225,8 +225,9 @@ async def async_update_interfaces( if listen_socket is not None and any( not _listen_socket_supports(listen_socket, interface) for interface in desired.values() ): - listen_socket = await self._async_rebuild_listen_socket(apple_p2p, desired, current) + await self._async_rebuild_listen_socket(apple_p2p, desired, current) listen_transport = self._listen_transport + listen_socket = listen_transport.sock if listen_transport is not None else None for bind_address, wrapped in current.items(): if bind_address in desired: @@ -310,7 +311,7 @@ async def _async_rebuild_listen_socket( apple_p2p: bool, desired: dict[tuple[str, int], str | tuple[tuple[str, int, int], int]], current: dict[tuple[str, int], _WrappedTransport], - ) -> socket.socket: + ) -> None: """Replace the listen socket with one whose family covers the desired set. The listen socket's family is otherwise fixed at construction; this @@ -351,7 +352,6 @@ async def _async_rebuild_listen_socket( old_transport = old_listen_transport.transport self._async_remove_transport(old_transport) old_transport.close() - return new_listen def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 253eb4d5..42d83e78 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -490,16 +490,10 @@ async def test_update_interfaces_does_not_rebuild_when_family_supported( @pytest.mark.asyncio -async def test_update_interfaces_rebuilds_listen_socket_for_new_family( - aiozc_loopback: AsyncZeroconf, -) -> None: - """Adding an interface of an unsupported family rebuilds the listen socket once.""" +async def test_update_interfaces_rebuild_rejoins_kept_interfaces(aiozc_loopback: AsyncZeroconf) -> None: + """On a family-change rebuild, interfaces that stay are re-joined on the new listen socket.""" engine = aiozc_loopback.zeroconf.engine await aiozc_loopback.zeroconf.async_wait_for_start() - old_listen = engine._listen_transport - assert old_listen is not None - assert old_listen.sock.family == socket.AF_INET # V4Only loopback instance - old_underlying = old_listen.transport # Keep the existing IPv4 interface and add an IPv6 one (which the IPv4 # listen socket can't join, forcing a rebuild). @@ -514,7 +508,7 @@ async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: with ( patch.object(_engine, "normalize_interface_choice", return_value=["127.0.0.1", v6]), - patch.object(_engine, "new_socket", return_value=new_listen_sock) as mock_new_socket, + patch.object(_engine, "new_socket", return_value=new_listen_sock), patch.object(_engine, "add_multicast_member", return_value=True) as mock_add, patch.object(_engine, "new_respond_socket", return_value=Mock()), patch.object(_engine, "drop_multicast_member"), @@ -522,17 +516,11 @@ async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: ): added = await engine.async_update_interfaces(["unused"], IPVersion.All, False) - # Rebuilt exactly once, the v6 sender added, the listen transport swapped, - mock_new_socket.assert_called_once() assert added is True - assert engine._listen_transport is not old_listen - # the kept IPv4 interface was re-joined on the new listen socket, + # The kept IPv4 interface was re-joined on the new listen socket. assert any( call.args[0] is new_listen_sock and call.args[1] == "127.0.0.1" for call in mock_add.call_args_list ) - # and the old listen socket closed and removed (no duplicate left behind). - assert old_underlying.is_closing() - assert old_listen not in engine.readers @pytest.mark.asyncio From 2058592ab5c45a19b594a079ed0354b898e99b5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 00:33:51 -0500 Subject: [PATCH 09/23] refactor: share interface-setup helpers between construction and rescan Factor the per-interface join+responder primitive into net.add_interface, used by both create_sockets and the runtime _async_add_interface, and a new_listen_socket helper shared by create_sockets and the rebuild path. Collapse the duplicated deferred-state teardown in _listener into a _drop_deferred helper used by both cancel_pending_timers and _evict_oldest_deferred. Hoist the listen interface_key out of the dual-use guard's any() so it is computed once. --- src/zeroconf/_engine.py | 35 ++++++++---------- src/zeroconf/_listener.pxd | 2 ++ src/zeroconf/_listener.py | 22 ++++++------ src/zeroconf/_utils/net.py | 41 +++++++++++++++------ tests/test_interface_update.py | 65 ++++++++++++---------------------- tests/utils/test_net.py | 51 ++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 84 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index ff937f81..317499a7 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -33,10 +33,10 @@ from ._utils.net import ( InterfacesType, IPVersion, + add_interface, add_multicast_member, drop_multicast_member, - new_respond_socket, - new_socket, + new_listen_socket, normalize_interface_choice, ) from ._utils.time import current_time_millis @@ -207,15 +207,15 @@ async def async_update_interfaces( # supported, so raise (before any state changes) rather than # double-send. The no-arg refresh of a Default instance keys to the # listen socket and never reaches here. - if ( - listen_transport is not None - and any(wrapped.transport is listen_transport.transport for wrapped in self.senders) - and any(key != listen_transport.interface_key for key in desired) + if listen_transport is not None and any( + wrapped.transport is listen_transport.transport for wrapped in self.senders ): - raise RuntimeError( - "Cannot change interfaces on a Default single-family Zeroconf instance; " - "recreate it to use an explicit interface set" - ) + listen_key = listen_transport.interface_key + if any(key != listen_key for key in desired): + raise RuntimeError( + "Cannot change interfaces on a Default single-family Zeroconf instance; " + "recreate it to use an explicit interface set" + ) # The listen socket's family is fixed at construction. If a desired # interface cannot be joined on it (e.g. an IPv6 interface added to an @@ -256,19 +256,14 @@ async def _async_add_interface( Returns whether a responder socket was actually added. """ - # A unicast instance has no listen socket, so membership is only - # ever managed when ``listen_socket`` is present. These are + # Join the group and create the responder via the same primitive + # construction uses, so setup and rescan stay in lockstep. These are # user-initiated reconciles, so a requested interface that fails to # come up is surfaced once at warning (deduped per interface so the # polling monitor doesn't spam) rather than only at debug. - if listen_socket is not None and not add_multicast_member(listen_socket, interface): - self.zc.log_warning_once(f"Interface {interface!r} not added: could not join multicast group") - return False - respond_socket = new_respond_socket(interface, apple_p2p=apple_p2p, unicast=self.zc.unicast) + respond_socket = add_interface(listen_socket, interface, apple_p2p=apple_p2p, unicast=self.zc.unicast) if respond_socket is None: - if listen_socket is not None: - drop_multicast_member(listen_socket, interface) - self.zc.log_warning_once(f"Interface {interface!r} not added: no responder socket") + self.zc.log_warning_once(f"Interface {interface!r} not added") return False try: await self._async_wrap_socket(respond_socket, is_sender=True) @@ -331,7 +326,7 @@ async def _async_rebuild_listen_socket( family_version = IPVersion.V6Only else: family_version = IPVersion.V4Only - new_listen = new_socket(bind_addr=("",), ip_version=family_version, apple_p2p=apple_p2p) + new_listen = new_listen_socket(family_version, apple_p2p) if new_listen is None: raise RuntimeError("Failed to create a listen socket for the new interface family") try: diff --git a/src/zeroconf/_listener.pxd b/src/zeroconf/_listener.pxd index 260ba091..b5001a25 100644 --- a/src/zeroconf/_listener.pxd +++ b/src/zeroconf/_listener.pxd @@ -43,6 +43,8 @@ cdef class AsyncListener: cdef _cancel_any_timers_for_addr(self, object addr) + cdef _drop_deferred(self, object addr) + cdef _evict_oldest_deferred(self) @cython.locals(deadline=object, fire_at=double) diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 2459b8ea..f50aba6f 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -309,17 +309,22 @@ def _cancel_any_timers_for_addr(self, addr: _str) -> None: if addr in self._timers: self._timers.pop(addr).cancel() + def _drop_deferred(self, addr: _str) -> None: + """Cancel an address's timer and discard its reassembly state.""" + self._cancel_any_timers_for_addr(addr) + self._deferred_deadlines.pop(addr, None) + self._deferred.pop(addr, None) + def cancel_pending_timers(self) -> None: """Cancel all pending TC-reassembly timers and drop deferred state. Called when this listener's transport is removed so a timer cannot - fire a response against an already-closed transport. + fire a response against an already-closed transport. Every timer's + addr also has a deferred entry, so dropping each deferred addr + cancels its timer too. """ - for timer in self._timers.values(): - timer.cancel() - self._timers.clear() - self._deferred.clear() - self._deferred_deadlines.clear() + for addr in list(self._deferred): + self._drop_deferred(addr) def _evict_oldest_deferred(self) -> None: """Discard the oldest deferred addr's reassembly state. @@ -331,10 +336,7 @@ def _evict_oldest_deferred(self) -> None: order) rather than LRU so an active flooder cannot pin its slots by re-sending into the same addr. """ - oldest_addr = next(iter(self._deferred)) - self._cancel_any_timers_for_addr(oldest_addr) - self._deferred_deadlines.pop(oldest_addr, None) - del self._deferred[oldest_addr] + self._drop_deferred(next(iter(self._deferred))) def _respond_query( self, diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 656f0508..abd8434f 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -476,16 +476,41 @@ def new_respond_socket( return respond_socket +def new_listen_socket( + ip_version: IPVersion = IPVersion.V4Only, apple_p2p: bool = False +) -> socket.socket | None: + """Create the shared wildcard socket used to receive multicast queries.""" + return new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p) + + +def add_interface( + listen_socket: socket.socket | None, + interface: str | tuple[tuple[str, int, int], int], + apple_p2p: bool = False, + unicast: bool = False, +) -> socket.socket | None: + """Join an interface's multicast group and create its responder socket. + + Returns the responder socket, or None if the interface can't be brought + up. A group membership joined here is rolled back if the responder socket + cannot be created, so a failure leaves no dangling membership. Shared by + construction (``create_sockets``) and the runtime rescan. + """ + if listen_socket is not None and not add_multicast_member(listen_socket, interface): + return None + respond_socket = new_respond_socket(interface, apple_p2p=apple_p2p, unicast=unicast) + if respond_socket is None and listen_socket is not None: + drop_multicast_member(listen_socket, interface) + return respond_socket + + def create_sockets( interfaces: InterfacesType = InterfaceChoice.All, unicast: bool = False, ip_version: IPVersion = IPVersion.V4Only, apple_p2p: bool = False, ) -> tuple[socket.socket | None, list[socket.socket]]: - if unicast: - listen_socket = None - else: - listen_socket = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p) + listen_socket = None if unicast else new_listen_socket(ip_version, apple_p2p) normalized_interfaces = normalize_interface_choice(interfaces, ip_version) @@ -499,14 +524,8 @@ def create_sockets( return listen_socket, [cast(socket.socket, listen_socket)] respond_sockets = [] - for interface in normalized_interfaces: - # Only create response socket if unicast or becoming multicast member was successful - if not unicast and not add_multicast_member(cast(socket.socket, listen_socket), interface): - continue - - respond_socket = new_respond_socket(interface, apple_p2p=apple_p2p, unicast=unicast) - + respond_socket = add_interface(listen_socket, interface, apple_p2p=apple_p2p, unicast=unicast) if respond_socket is not None: respond_sockets.append(respond_socket) diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 42d83e78..ebc38721 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -261,8 +261,7 @@ def _fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: # This test exercises the diff over a contrived sender set, not the # listen-socket rebuild, so treat every family as supported. patch.object(_engine, "_listen_socket_supports", return_value=True), - patch.object(_engine, "add_multicast_member", return_value=True), - patch.object(_engine, "new_respond_socket", return_value=Mock()), + patch.object(_engine, "add_interface", return_value=Mock()), patch.object(_engine, "drop_multicast_member") as mock_drop, patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=_fake_wrap)), ): @@ -360,36 +359,17 @@ async def test_update_interfaces_logs_reannounce_errors( @pytest.mark.asyncio -async def test_update_interfaces_add_membership_failure(aiozc_loopback: AsyncZeroconf) -> None: - """A failed multicast join skips adding the responder socket.""" - zc = aiozc_loopback.zeroconf - await zc.async_wait_for_start() - engine = zc.engine - await aiozc_loopback.async_update_interfaces([]) - await asyncio.sleep(0) - - with patch.object(_engine, "add_multicast_member", return_value=False): - await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) - assert engine.senders == [] - - -@pytest.mark.asyncio -async def test_update_interfaces_respond_socket_none_rolls_back(aiozc_loopback: AsyncZeroconf) -> None: - """A None responder socket drops the membership just joined.""" +async def test_update_interfaces_add_failure_adds_no_sender(aiozc_loopback: AsyncZeroconf) -> None: + """An interface that fails to come up adds no responder socket.""" zc = aiozc_loopback.zeroconf await zc.async_wait_for_start() engine = zc.engine await aiozc_loopback.async_update_interfaces([]) await asyncio.sleep(0) - with ( - patch.object(_engine, "add_multicast_member", return_value=True), - patch.object(_engine, "new_respond_socket", return_value=None), - patch.object(_engine, "drop_multicast_member") as mock_drop, - ): + with patch.object(_engine, "add_interface", return_value=None): await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) assert engine.senders == [] - mock_drop.assert_called_once() @pytest.mark.asyncio @@ -405,8 +385,7 @@ async def test_update_interfaces_rolls_back_membership_on_wrap_failure( fake_socket = Mock() with ( - patch.object(_engine, "add_multicast_member", return_value=True), - patch.object(_engine, "new_respond_socket", return_value=fake_socket), + patch.object(_engine, "add_interface", return_value=fake_socket), patch.object(_engine, "drop_multicast_member") as mock_drop, patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))), pytest.raises(OSError), @@ -428,7 +407,7 @@ async def test_add_interface_rollback_without_listen_socket(aiozc_loopback: Asyn fake_socket = Mock() with ( - patch.object(_engine, "new_respond_socket", return_value=fake_socket), + patch.object(_engine, "add_interface", return_value=fake_socket), patch.object(_engine, "drop_multicast_member") as mock_drop, patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))), pytest.raises(OSError), @@ -462,13 +441,13 @@ async def test_update_interfaces_default_explicit_list_raises(aiozc_loopback: As engine.senders = [listen] with ( patch.object(_engine, "normalize_interface_choice", return_value=["192.168.1.5"]), - patch.object(_engine, "new_socket") as mock_new_socket, + patch.object(_engine, "new_listen_socket") as mock_new_listen, pytest.raises(RuntimeError, match="Default single-family"), ): await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False) assert engine.senders == [listen] # The dual-use guard takes precedence; it never falls through to a rebuild. - mock_new_socket.assert_not_called() + mock_new_listen.assert_not_called() @pytest.mark.asyncio @@ -479,13 +458,13 @@ async def test_update_interfaces_does_not_rebuild_when_family_supported( zc = aiozc_loopback.zeroconf await zc.async_wait_for_start() listen = zc.engine._listen_transport - with patch.object(_engine, "new_socket") as mock_new_socket: + with patch.object(_engine, "new_listen_socket") as mock_new_listen: await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) await aiozc_loopback.async_update_interfaces([]) await asyncio.sleep(0) await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) await asyncio.sleep(0) - mock_new_socket.assert_not_called() + mock_new_listen.assert_not_called() assert zc.engine._listen_transport is listen @@ -508,9 +487,9 @@ async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: with ( patch.object(_engine, "normalize_interface_choice", return_value=["127.0.0.1", v6]), - patch.object(_engine, "new_socket", return_value=new_listen_sock), + patch.object(_engine, "new_listen_socket", return_value=new_listen_sock), patch.object(_engine, "add_multicast_member", return_value=True) as mock_add, - patch.object(_engine, "new_respond_socket", return_value=Mock()), + patch.object(_engine, "add_interface", return_value=Mock()), patch.object(_engine, "drop_multicast_member"), patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=fake_wrap)), ): @@ -530,7 +509,7 @@ async def test_update_interfaces_rebuild_failure_raises(aiozc_loopback: AsyncZer await aiozc_loopback.zeroconf.async_wait_for_start() with ( patch.object(_engine, "normalize_interface_choice", return_value=[(("fe80::1", 0, 0), 1)]), - patch.object(_engine, "new_socket", return_value=None), + patch.object(_engine, "new_listen_socket", return_value=None), pytest.raises(RuntimeError, match="listen socket"), ): await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) @@ -549,7 +528,7 @@ async def test_update_interfaces_rebuild_closes_socket_on_wrap_failure( with ( patch.object(_engine, "normalize_interface_choice", return_value=[(("fe80::1", 0, 0), 1)]), - patch.object(_engine, "new_socket", return_value=new_listen_sock), + patch.object(_engine, "new_listen_socket", return_value=new_listen_sock), patch.object(_engine, "add_multicast_member", return_value=True), patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))), pytest.raises(OSError), @@ -579,9 +558,9 @@ async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: with ( patch.object(_engine, "normalize_interface_choice", return_value=["192.168.1.5"]), patch.object(_engine, "_listen_socket_supports", return_value=False), # force a rebuild - patch.object(_engine, "new_socket", return_value=new_listen_sock) as mock_new_socket, + patch.object(_engine, "new_listen_socket", return_value=new_listen_sock) as mock_new_listen, patch.object(_engine, "add_multicast_member", return_value=True), - patch.object(_engine, "new_respond_socket", return_value=Mock()), + patch.object(_engine, "add_interface", return_value=Mock()), patch.object(_engine, "drop_multicast_member"), patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=fake_wrap)), ): @@ -589,8 +568,8 @@ async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: # rebuilt socket is IPv4 (covers the set; no immediate re-rebuild). await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) - mock_new_socket.assert_called_once() - assert mock_new_socket.call_args.kwargs["ip_version"] is IPVersion.V4Only + mock_new_listen.assert_called_once() + assert mock_new_listen.call_args.args[0] is IPVersion.V4Only @pytest.mark.asyncio @@ -604,12 +583,12 @@ async def test_update_interfaces_rebuilds_real_listen_socket(aiozc_loopback: Asy old_underlying = old_listen.transport v6 = (("fe80::1", 0, 0), 1) - # Real new_socket + _async_wrap_socket run; only membership joins and the - # (unbindable) v6 responder are stubbed so no real multicast is exercised. + # Real new_listen_socket + _async_wrap_socket run; only membership joins and + # the (unbindable) v6 responder are stubbed so no real multicast is exercised. with ( patch.object(_engine, "normalize_interface_choice", return_value=["127.0.0.1", v6]), patch.object(_engine, "add_multicast_member", return_value=True), - patch.object(_engine, "new_respond_socket", return_value=None), + patch.object(_engine, "add_interface", return_value=None), ): await engine.async_update_interfaces(["unused"], IPVersion.All, False) @@ -677,7 +656,7 @@ async def test_update_interfaces_unicast_has_no_listen_socket() -> None: assert engine.senders == [] # A None responder socket has no membership to roll back without a listen socket. with ( - patch.object(_engine, "new_respond_socket", return_value=None), + patch.object(_engine, "add_interface", return_value=None), patch.object(_engine, "drop_multicast_member") as mock_drop, ): await aiozc.async_update_interfaces(["127.0.0.1"]) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index dad2cb27..8d919daf 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -384,6 +384,57 @@ def test_new_respond_socket_new_socket_returns_none(): assert netutils.new_respond_socket(("0.0.0.0", 0)) is None # type: ignore[arg-type] +def test_add_interface_returns_responder_on_success(): + """add_interface joins the group and returns the responder socket.""" + listen_socket = Mock() + respond_socket = Mock() + with ( + patch.object(netutils, "add_multicast_member", return_value=True) as mock_add, + patch.object(netutils, "new_respond_socket", return_value=respond_socket), + patch.object(netutils, "drop_multicast_member") as mock_drop, + ): + assert netutils.add_interface(listen_socket, "127.0.0.1") is respond_socket + mock_add.assert_called_once_with(listen_socket, "127.0.0.1") + mock_drop.assert_not_called() + + +def test_add_interface_join_failure_returns_none(): + """A failed multicast join returns None and never creates a responder.""" + listen_socket = Mock() + with ( + patch.object(netutils, "add_multicast_member", return_value=False), + patch.object(netutils, "new_respond_socket") as mock_respond, + patch.object(netutils, "drop_multicast_member") as mock_drop, + ): + assert netutils.add_interface(listen_socket, "127.0.0.1") is None + mock_respond.assert_not_called() + mock_drop.assert_not_called() + + +def test_add_interface_responder_failure_rolls_back_membership(): + """A None responder socket drops the membership just joined.""" + listen_socket = Mock() + with ( + patch.object(netutils, "add_multicast_member", return_value=True), + patch.object(netutils, "new_respond_socket", return_value=None), + patch.object(netutils, "drop_multicast_member") as mock_drop, + ): + assert netutils.add_interface(listen_socket, "127.0.0.1") is None + mock_drop.assert_called_once_with(listen_socket, "127.0.0.1") + + +def test_add_interface_no_listen_socket_skips_membership(): + """Without a listen socket (unicast) no membership op runs and rollback is skipped.""" + with ( + patch.object(netutils, "add_multicast_member") as mock_add, + patch.object(netutils, "new_respond_socket", return_value=None), + patch.object(netutils, "drop_multicast_member") as mock_drop, + ): + assert netutils.add_interface(None, "127.0.0.1", unicast=True) is None + mock_add.assert_not_called() + mock_drop.assert_not_called() + + def test_create_sockets_interfaces_all_unicast(): """Test create_sockets with unicast.""" From 2d4b61b4bdcae3cdc2114bc18585e8fe691e337a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 00:40:14 -0500 Subject: [PATCH 10/23] feat: reconcile a Default single-family instance to an explicit set Instead of raising, moving a Default single-family instance to an explicit interface set now demotes its dual-use listen/responder socket to a pure listener and rebuilds it clean, then adds per-interface responders for the whole desired set. Rebuilding releases the dual-use socket's existing group memberships so the new joins do not collide (EADDRINUSE) when the desired set overlaps the interface it served, and demoting it first prevents double announcements. --- src/zeroconf/_core.py | 5 +-- src/zeroconf/_engine.py | 56 ++++++++++++++++------------- tests/test_interface_update.py | 65 +++++++++++++++++++++++++++++----- 3 files changed, 91 insertions(+), 35 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 309c35ce..e4c371fd 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -448,8 +448,9 @@ async def async_update_interfaces( ``ip_version`` and ``apple_p2p`` each default to the value passed at construction; pass a new value to switch it. When the resulting interface set is unchanged this is a no-op (no sockets touched, - nothing re-announced). The shared listen socket's family and unicast - mode are fixed at construction. Concurrent calls are serialized. + nothing re-announced). The listen socket is rebuilt if the new set + needs a different address family; unicast mode is fixed at + construction. Concurrent calls are serialized. """ # Resolve against the retained config but only commit it after the # engine reconcile succeeds, so a failed reconcile leaves the stored diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 317499a7..1116aa69 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -189,9 +189,10 @@ async def async_update_interfaces( Adds a per-interface responder socket for each interface that appeared and tears down the socket for each interface that - disappeared, diffing on the bound address. The shared listen - socket (including the Default single-family dual-use socket) is - never torn down here. Returns whether any responder socket was + disappeared, diffing on the bound address. A Default single-family + instance's dual-use listen/responder socket is converted to a pure + listener when moving to an explicit set; otherwise the shared listen + socket is left intact. Returns whether any responder socket was added, so the caller can skip re-announcing when nothing appeared. """ assert self.loop is not None @@ -201,30 +202,35 @@ async def async_update_interfaces( listen_transport = self._listen_transport listen_socket = listen_transport.sock if listen_transport is not None else None - # A Default single-family instance shares the listen socket as its - # only sender; adding per-interface senders alongside it would double - # every announcement. Switching interface kind at runtime is not - # supported, so raise (before any state changes) rather than - # double-send. The no-arg refresh of a Default instance keys to the - # listen socket and never reaches here. + # The listen socket's family is fixed at construction, so a desired + # interface of another family (e.g. an IPv6 interface added to an IPv4 + # instance) needs a fresh listen socket before senders are reconciled, + # otherwise the current senders would be torn down with no replacements + # bound. + needs_rebuild = listen_socket is not None and any( + not _listen_socket_supports(listen_socket, interface) for interface in desired.values() + ) + + # A Default single-family instance shares the listen socket as its only + # sender (the dual-use socket). Moving it to an explicit interface set + # abandons that optimization: demote the socket so it stops responding + # (otherwise it would double every announcement on the overlapping + # interface) and rebuild it as a pure listener (its existing group + # memberships would otherwise collide with the new per-interface joins). + # Once demoted it no longer counts as a per-interface sender, so the + # interface it served gets a fresh responder like any other. The no-arg + # refresh of a Default instance leaves desired == {its interface} and so + # neither demotes nor rebuilds. if listen_transport is not None and any( wrapped.transport is listen_transport.transport for wrapped in self.senders ): listen_key = listen_transport.interface_key if any(key != listen_key for key in desired): - raise RuntimeError( - "Cannot change interfaces on a Default single-family Zeroconf instance; " - "recreate it to use an explicit interface set" - ) - - # The listen socket's family is fixed at construction. If a desired - # interface cannot be joined on it (e.g. an IPv6 interface added to an - # IPv4 instance), rebuild the listen socket for the new family before - # reconciling senders, otherwise the current senders would be torn down - # with no replacements bound. - if listen_socket is not None and any( - not _listen_socket_supports(listen_socket, interface) for interface in desired.values() - ): + self.senders = [w for w in self.senders if w.transport is not listen_transport.transport] + current = {wrapped.interface_key: wrapped for wrapped in self.senders} + needs_rebuild = True + + if needs_rebuild: await self._async_rebuild_listen_socket(apple_p2p, desired, current) listen_transport = self._listen_transport listen_socket = listen_transport.sock if listen_transport is not None else None @@ -310,9 +316,9 @@ async def _async_rebuild_listen_socket( """Replace the listen socket with one whose family covers the desired set. The listen socket's family is otherwise fixed at construction; this - lets an instance start receiving a newly added address family. Only - called when a desired interface cannot be joined on the current listen - socket. The replacement family is derived from the desired set (not the + lets an instance start receiving a newly added address family, and is + also used to convert a Default dual-use socket to a pure listener. The + replacement family is derived from the desired set (not the requested ip_version, which an explicit list can contradict) so it always covers every desired interface and never needs an immediate re-rebuild. Interfaces that are staying are re-joined on the new socket, diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index ebc38721..05f78a3e 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -432,22 +432,71 @@ async def test_update_interfaces_keeps_dual_use_listen_socket(aiozc_loopback: As @pytest.mark.asyncio -async def test_update_interfaces_default_explicit_list_raises(aiozc_loopback: AsyncZeroconf) -> None: - """An explicit set on a dual-use instance raises before any state change or rebuild.""" +async def test_update_interfaces_default_to_explicit_reconciles(aiozc_loopback: AsyncZeroconf) -> None: + """Moving a dual-use instance to an explicit set demotes its socket and rebuilds clean.""" engine = aiozc_loopback.zeroconf.engine await aiozc_loopback.zeroconf.async_wait_for_start() listen = engine._listen_transport assert listen is not None + old_underlying = listen.transport + # Simulate a Default single-family instance: the listen socket is the sole sender. engine.senders = [listen] + new_listen_sock = Mock() + new_listen_sock.family = socket.AF_INET + + async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: + wrapped = _make_wrapped(("wrapped", 0), transport=Mock()) + (engine.senders if is_sender else engine.readers).append(wrapped) + return wrapped + with ( patch.object(_engine, "normalize_interface_choice", return_value=["192.168.1.5"]), - patch.object(_engine, "new_listen_socket") as mock_new_listen, - pytest.raises(RuntimeError, match="Default single-family"), + patch.object(_engine, "new_listen_socket", return_value=new_listen_sock) as mock_new_listen, + patch.object(_engine, "add_interface", return_value=Mock()), + patch.object(_engine, "drop_multicast_member"), + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=fake_wrap)), ): - await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False) - assert engine.senders == [listen] - # The dual-use guard takes precedence; it never falls through to a rebuild. - mock_new_listen.assert_not_called() + added = await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False) + + # The dual-use socket is rebuilt as a pure listener (demoted and closed), + # a fresh listener replaces it, and the explicit interface gains a responder. + assert added is True + mock_new_listen.assert_called_once() + assert engine._listen_transport is not listen + assert listen not in engine.senders + assert listen not in engine.readers + assert old_underlying.is_closing() + # One brand-new responder (for 192.168.1.5) is the only sender now. + assert len(engine.senders) == 1 + assert engine.senders[0] is not listen + + +@pytest.mark.asyncio +async def test_update_interfaces_default_to_explicit_real(aiozc_loopback: AsyncZeroconf) -> None: + """A real dual-use socket with an overlapping membership reconciles without EADDRINUSE.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + listen = engine._listen_transport + assert listen is not None + assert listen.sock.family == socket.AF_INET + old_underlying = listen.transport + # Simulate a Default dual-use instance whose listen socket already joined + # the loopback group, so a naive demote-and-rejoin would hit EADDRINUSE. + _engine.add_multicast_member(listen.sock, "127.0.0.1") + engine.senders = [listen] + + with patch.object(_engine, "normalize_interface_choice", return_value=["127.0.0.1"]): + added = await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False) + + assert added is True + new_listen = engine._listen_transport + assert new_listen is not None + assert new_listen is not listen + assert new_listen.sock.family == socket.AF_INET + assert old_underlying.is_closing() + # The overlapping interface got a real responder on the fresh listen socket. + assert len(engine.senders) == 1 + assert engine.senders[0] is not listen @pytest.mark.asyncio From 2b37423eff40fd39f0c7e8d1dcdf1d6dd50e4dfa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 00:41:15 -0500 Subject: [PATCH 11/23] docs: correct the no-await invariant comment in _async_create_endpoints --- src/zeroconf/_engine.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 1116aa69..5ca1708b 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -151,9 +151,10 @@ async def _async_create_endpoints(self) -> None: for s in reader_sockets: reader = await self._async_wrap_socket(s, s in sender_sockets) - # The wrap above does not await before returning, so releasing - # the engine's pending handle here keeps ``s`` in exactly one - # place from a concurrent shutdown's point of view. + # _async_wrap_socket registers the transport with no await between + # creating and registering it, and the pending-handle cleanup below + # adds no await either, so a concurrent shutdown always sees ``s`` + # in exactly one place. if s is self._listen_socket: # Keep a handle to the shared listen socket so interface # rescans can add/drop multicast memberships on it. From 59d9c80babef4e7fc376c4bf76102677627a8fdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 00:49:44 -0500 Subject: [PATCH 12/23] refactor: dedup transport-list filtering; pop the demoted key instead of recomputing Add a _without_transport helper for the repeated 'filter wrappers whose transport is not X' list comprehension, used by _async_remove_transport and the dual-use demote. In the demote, pop the known listen key from current instead of rebuilding the whole dict. --- src/zeroconf/_engine.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 5ca1708b..e14ad577 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -83,6 +83,13 @@ def _listen_socket_supports( return True +def _without_transport( + wrappers: list[_WrappedTransport], transport: asyncio.DatagramTransport +) -> list[_WrappedTransport]: + """Return the wrappers whose underlying transport is not ``transport``.""" + return [wrapped for wrapped in wrappers if wrapped.transport is not transport] + + class AsyncEngine: """An engine wraps sockets in the event loop.""" @@ -227,8 +234,8 @@ async def async_update_interfaces( ): listen_key = listen_transport.interface_key if any(key != listen_key for key in desired): - self.senders = [w for w in self.senders if w.transport is not listen_transport.transport] - current = {wrapped.interface_key: wrapped for wrapped in self.senders} + self.senders = _without_transport(self.senders, listen_transport.transport) + current.pop(listen_key, None) needs_rebuild = True if needs_rebuild: @@ -294,8 +301,8 @@ def _async_remove_transport(self, transport: asyncio.DatagramTransport) -> None: else: kept_protocols.append(protocol) self.protocols = kept_protocols - self.readers = [w for w in self.readers if w.transport is not transport] - self.senders = [w for w in self.senders if w.transport is not transport] + self.readers = _without_transport(self.readers, transport) + self.senders = _without_transport(self.senders, transport) def _async_close_sender(self, wrapped: _WrappedTransport, listen_socket: socket.socket | None) -> None: """Drop a per-interface sender's wrappers/protocol and close its transport.""" @@ -338,8 +345,14 @@ async def _async_rebuild_listen_socket( raise RuntimeError("Failed to create a listen socket for the new interface family") try: for bind_address, interface in desired.items(): - if bind_address in current: - add_multicast_member(new_listen, interface) + # A staying interface that can't re-join on the new socket keeps + # its sender but receives only via the shared socket it never + # joined; surface that degraded state like _async_add_interface. + if bind_address in current and not add_multicast_member(new_listen, interface): + self.zc.log_warning_once( + f"Interface {interface!r} could not re-join the multicast group " + "on the rebuilt listen socket" + ) new_reader = await self._async_wrap_socket(new_listen, is_sender=False) except Exception: # Endpoint creation failed; close the unadopted socket (and its From 12bba0094961d5e61a9ed8a94caa03b44d3939a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 00:49:45 -0500 Subject: [PATCH 13/23] fix: warn when a staying interface fails to re-join on the rebuilt listen socket The rebuild re-join discarded add_multicast_member's result, so a staying interface that could not re-join went silently send-only. Surface it with log_warning_once, mirroring _async_add_interface. --- tests/test_interface_update.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 05f78a3e..8d400110 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -551,6 +551,36 @@ async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: ) +@pytest.mark.asyncio +async def test_update_interfaces_rebuild_rejoin_failure_warns( + aiozc_loopback: AsyncZeroconf, caplog: pytest.LogCaptureFixture +) -> None: + """A staying interface that fails to re-join on the rebuilt listen socket warns.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + v6 = (("fe80::1", 0, 0), 1) + new_listen_sock = Mock() + new_listen_sock.family = socket.AF_INET6 + + async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: + wrapped = _make_wrapped(("wrapped", 0), transport=Mock()) + (engine.senders if is_sender else engine.readers).append(wrapped) + return wrapped + + with ( + patch.object(_engine, "normalize_interface_choice", return_value=["127.0.0.1", v6]), + patch.object(_engine, "new_listen_socket", return_value=new_listen_sock), + patch.object(_engine, "add_multicast_member", return_value=False), + patch.object(_engine, "add_interface", return_value=Mock()), + patch.object(_engine, "drop_multicast_member"), + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=fake_wrap)), + caplog.at_level(logging.WARNING), + ): + await engine.async_update_interfaces(["unused"], IPVersion.All, False) + + assert "could not re-join the multicast group on the rebuilt listen socket" in caplog.text + + @pytest.mark.asyncio async def test_update_interfaces_rebuild_failure_raises(aiozc_loopback: AsyncZeroconf) -> None: """If the replacement listen socket can't be created, the rebuild raises.""" From 46579752de3bed33c1b7841538a306cdd7b0e1c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 01:03:06 -0500 Subject: [PATCH 14/23] test: pin re-announce fan-out, ip-change, and v6 leave-index behavior Add regression guards the diff structurally couldn't fail on: re-announce over multiple registrations with one failing, an interface IP change handled as remove+add in one rescan, and the IPv6 leave packing the join interface index. Document that re-announcement is best-effort. --- src/zeroconf/_core.py | 4 +- tests/test_interface_update.py | 72 ++++++++++++++++++++++++++++++++++ tests/utils/test_net.py | 12 ++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index e4c371fd..2a2e1e43 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -450,7 +450,9 @@ async def async_update_interfaces( interface set is unchanged this is a no-op (no sockets touched, nothing re-announced). The listen socket is rebuilt if the new set needs a different address family; unicast mode is fixed at - construction. Concurrent calls are serialized. + construction. Concurrent calls are serialized. Re-announcement is + best-effort: a registration that fails to re-announce is logged, not + raised, so one failure cannot block the others. """ # Resolve against the retained config but only commit it after the # engine reconcile succeeds, so a failed reconcile leaves the stored diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 8d400110..2219206b 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -358,6 +358,78 @@ async def test_update_interfaces_logs_reannounce_errors( assert "Error re-announcing service after interface update" in caplog.text +@pytest.mark.asyncio +async def test_update_interfaces_reannounces_all_services_one_failing( + aiozc_loopback: AsyncZeroconf, caplog: pytest.LogCaptureFixture +) -> None: + """Every registration is re-announced on add; one failing does not stop the rest.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + infos = [ + ServiceInfo( + "_test._tcp.local.", + f"T{n}._test._tcp.local.", + addresses=[b"\x7f\x00\x00\x01"], + port=80 + n, + server=f"t{n}.local.", + ) + for n in range(2) + ] + for info in infos: + await aiozc_loopback.async_register_service(info) + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + + async def broadcast(info: ServiceInfo, *args: object) -> None: + if info is infos[0]: + raise ValueError("boom") + + with ( + patch.object(zc, "_async_broadcast_service", new_callable=AsyncMock, side_effect=broadcast) as mock, + caplog.at_level(logging.WARNING), + ): + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + await asyncio.sleep(0) + + # Both services were attempted (the gather fans out over all registrations) + # and the second still ran despite the first raising. + announced = {call.args[0] for call in mock.call_args_list} + assert announced == set(infos) + assert "Error re-announcing service after interface update" in caplog.text + + +@pytest.mark.asyncio +async def test_update_interfaces_ip_change_in_one_rescan(aiozc_loopback: AsyncZeroconf) -> None: + """An interface whose address changes is removed and re-added in a single rescan.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + old_transport = Mock() + old = _make_wrapped(("10.0.0.5", 5353), transport=old_transport) + engine.senders = [old] + + async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: + wrapped = _make_wrapped(("10.0.0.9", 5353), transport=Mock()) + (engine.senders if is_sender else engine.readers).append(wrapped) + return wrapped + + with ( + patch.object(_engine, "normalize_interface_choice", return_value=["10.0.0.9"]), + patch.object(_engine, "_listen_socket_supports", return_value=True), + patch.object(_engine, "add_interface", return_value=Mock()), + patch.object(_engine, "drop_multicast_member") as mock_drop, + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=fake_wrap)), + ): + added = await engine.async_update_interfaces(["unused"], IPVersion.All, False) + + assert added is True + # The old address left its group and was closed; exactly the new one remains. + assert {call.args[1] for call in mock_drop.call_args_list} == {"10.0.0.5"} + old_transport.close.assert_called_once() + assert old not in engine.senders + assert len(engine.senders) == 1 + assert engine.senders[0].interface_key == ("10.0.0.9", 0) + + @pytest.mark.asyncio async def test_update_interfaces_add_failure_adds_no_sender(aiozc_loopback: AsyncZeroconf) -> None: """An interface that fails to come up adds no responder socket.""" diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 8d919daf..b04a774d 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -4,6 +4,7 @@ import errno import socket +import struct import sys import unittest import warnings @@ -316,6 +317,17 @@ def test_drop_multicast_member() -> None: assert netutils.drop_multicast_member(sock, (("ff02::fb", 0, 0), 1)) is False # type: ignore[arg-type] +def test_drop_multicast_member_v6_uses_join_index() -> None: + """The IPv6 group leave packs the join interface index, not the bound scope_id.""" + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + with patch("socket.socket.setsockopt") as mock_set: + assert netutils.drop_multicast_member(sock, (("ff02::fb", 0, 0), 7)) is True # type: ignore[arg-type] + _level, optname, value = mock_set.call_args.args + assert optname == socket.IPV6_LEAVE_GROUP + # Trailing 4 bytes are the interface index the join used. + assert value[-4:] == struct.pack("@I", 7) + + def test_drop_multicast_member_wsaeinval(monkeypatch: pytest.MonkeyPatch) -> None: """On Windows, WSAEINVAL when leaving the group is treated as benign.""" monkeypatch.setattr(sys, "platform", "win32") From 4a2f0e35a613b2f1f69258e2fc34c3667782a807 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 08:57:35 -0500 Subject: [PATCH 15/23] fix: only swallow IPV6_V6ONLY read failure on Windows A bare except OSError in _listen_socket_supports masked a getsockopt failure on any platform into 'dual-stack supported', which could suppress a needed listen-socket rebuild and leave an added address family unreceivable. Narrow the fallback to win32 (where the read legitimately fails) and re-raise elsewhere, mirroring make_wrapped_transport. --- src/zeroconf/_engine.py | 16 +++++++++++----- tests/test_interface_update.py | 10 +++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index e14ad577..6e365736 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -25,6 +25,7 @@ import asyncio import itertools import socket +import sys import threading from typing import TYPE_CHECKING, cast @@ -74,13 +75,18 @@ def _listen_socket_supports( if listen_socket.family != socket.AF_INET6: # An IPv4 interface on an AF_INET socket. return True - # An IPv4 interface on an AF_INET6 socket: only when it is dual-stack. If - # the option can't be read (as Windows rejects some IPv6 getsockopts), - # assume supported so a read failure can't drive a rebuild loop. + # An IPv4 interface on an AF_INET6 socket: only when it is dual-stack. + supported = True try: - return not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) + supported = not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) except OSError: - return True + # Windows rejects reading IPV6_V6ONLY on some sockets; assume supported + # there (consistent with make_wrapped_transport) so a read failure can't + # drive a rebuild loop. Elsewhere the read does not fail, so surface a + # genuine error rather than mask an unreceivable family as supported. + if sys.platform != "win32": + raise + return supported def _without_transport( diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 2219206b..859f06a7 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -108,10 +108,14 @@ def test_listen_socket_supports_family() -> None: assert _listen_socket_supports(v6_sock, "1.2.3.4") is True v6_sock.getsockopt.return_value = 1 # IPV6_V6ONLY on -> v6-only assert _listen_socket_supports(v6_sock, "1.2.3.4") is False - # An unreadable option (some platforms) is treated as supported so it - # can't drive a rebuild loop. + # An unreadable option is expected only on Windows (WSAEINVAL); treat as + # supported there so it can't drive a rebuild loop, but surface a genuine + # read failure on other platforms rather than mask an unreceivable family. v6_sock.getsockopt.side_effect = OSError - assert _listen_socket_supports(v6_sock, "1.2.3.4") is True + with patch("zeroconf._engine.sys.platform", "win32"): + assert _listen_socket_supports(v6_sock, "1.2.3.4") is True + with patch("zeroconf._engine.sys.platform", "linux"), pytest.raises(OSError): + _listen_socket_supports(v6_sock, "1.2.3.4") @pytest.mark.asyncio From 4086ae246fc85023191181ac9710898114446f96 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 09:12:52 -0500 Subject: [PATCH 16/23] fix: name the service in re-announce failures; widen sync update timeout The re-announce warning now identifies which registration failed (zipped back to its ServiceInfo) so a partial failure is actionable. The sync update_interfaces wrapper awaits the announce inline (to keep per-service failure logging), so widen its timeout to cover the full announce window plus reconcile overhead rather than leaving a thin margin under EventLoopBlocked. --- src/zeroconf/_core.py | 19 +++++++++++-------- tests/test_interface_update.py | 7 +++++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 2a2e1e43..56f20893 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -428,10 +428,14 @@ def update_interfaces( call to `async_update_interfaces` cannot be completed. """ assert self.loop is not None + # Unlike register/update, the re-announce is awaited inline (to log + # per-service failures), so the budget must cover the full announce + # window ((_REGISTER_BROADCASTS - 1) * _REGISTER_TIME) plus the reconcile + # and wait-for-start overhead; double the register budget for headroom. run_coro_with_timeout( self.async_update_interfaces(interfaces, ip_version, apple_p2p), self.loop, - _REGISTER_TIME * _REGISTER_BROADCASTS, + _REGISTER_TIME * _REGISTER_BROADCASTS * 2, ) async def async_update_interfaces( @@ -476,17 +480,16 @@ async def async_update_interfaces( if not added: return # Re-announce every registration; one broadcast failing must not mask - # the rest, so collect exceptions and log them individually. + # the rest, so collect exceptions and log them individually, naming the + # service so a partial failure is actionable. + infos = self.registry.async_get_service_infos() results = await asyncio.gather( - *[ - self._async_broadcast_service(info, _REGISTER_TIME, None) - for info in self.registry.async_get_service_infos() - ], + *[self._async_broadcast_service(info, _REGISTER_TIME, None) for info in infos], return_exceptions=True, ) - for result in results: + for info, result in zip(infos, results, strict=True): if isinstance(result, Exception): - log.warning("Error re-announcing service after interface update: %s", result) + log.warning("Error re-announcing %s after interface update: %s", info.name, result) async def async_get_service_info( self, diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 859f06a7..04ab97f1 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -359,7 +359,8 @@ async def test_update_interfaces_logs_reannounce_errors( await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) await asyncio.sleep(0) - assert "Error re-announcing service after interface update" in caplog.text + # The failing service is named so a partial failure is actionable. + assert "Error re-announcing Test._test._tcp.local. after interface update" in caplog.text @pytest.mark.asyncio @@ -399,7 +400,9 @@ async def broadcast(info: ServiceInfo, *args: object) -> None: # and the second still ran despite the first raising. announced = {call.args[0] for call in mock.call_args_list} assert announced == set(infos) - assert "Error re-announcing service after interface update" in caplog.text + # Only the failing service is named in the warning. + assert f"Error re-announcing {infos[0].name} after interface update" in caplog.text + assert infos[1].name not in caplog.text @pytest.mark.asyncio From 37f6b1af3448440446869f36734dd76412b7eb7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 09:38:23 -0500 Subject: [PATCH 17/23] fix: degrade getsockopt read failures instead of aborting 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. --- src/zeroconf/_core.py | 3 ++- src/zeroconf/_engine.py | 15 +++++++-------- src/zeroconf/_transport.py | 18 ++++++++---------- tests/test_interface_update.py | 23 +++++++++-------------- 4 files changed, 26 insertions(+), 33 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 56f20893..effdaee2 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -460,7 +460,8 @@ async def async_update_interfaces( """ # Resolve against the retained config but only commit it after the # engine reconcile succeeds, so a failed reconcile leaves the stored - # values matching the sockets actually bound. + # config unchanged rather than recording a set that never fully bound + # (a mid-reconcile failure may still have changed some sockets). interfaces = self._interfaces if interfaces is None else interfaces ip_version = self._ip_version if ip_version is None else ip_version apple_p2p = self._apple_p2p if apple_p2p is None else apple_p2p diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 6e365736..2cdc1840 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -25,10 +25,10 @@ import asyncio import itertools import socket -import sys import threading from typing import TYPE_CHECKING, cast +from ._logger import log from ._record_update import RecordUpdate from ._utils.asyncio import get_running_loop, run_coro_with_timeout from ._utils.net import ( @@ -79,13 +79,12 @@ def _listen_socket_supports( supported = True try: supported = not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) - except OSError: - # Windows rejects reading IPV6_V6ONLY on some sockets; assume supported - # there (consistent with make_wrapped_transport) so a read failure can't - # drive a rebuild loop. Elsewhere the read does not fail, so surface a - # genuine error rather than mask an unreceivable family as supported. - if sys.platform != "win32": - raise + except OSError as exc: + # Reading IPV6_V6ONLY can fail (Windows rejects it on some sockets; + # other platforms shouldn't). Assume dual-stack so a read failure can't + # abort the rescan; at worst this skips a rebuild the next reconcile + # re-evaluates, consistent with make_wrapped_transport's fallback. + log.debug("Unable to read IPV6_V6ONLY, assuming dual-stack: %s", exc) return supported diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index ac106356..54bafade 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -24,9 +24,10 @@ import asyncio import socket -import sys from typing import cast +from ._logger import log + def _strip_zone(address: str) -> str: """Drop a ``%zone`` suffix from an IPv6 address string.""" @@ -103,17 +104,14 @@ def make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTran if is_ipv6: # IPV6_MULTICAST_IF holds the interface index new_respond_socket # joined the group with; capture it so a later group leave uses the - # same index. Windows rejects reading the option (WSAEINVAL); there - # the leave falls back to the default interface as it did before. + # same index. This is on the startup/connection path, and the index + # only selects the interface for a future (benign) group leave, so a + # read failure (Windows rejects it with WSAEINVAL; other platforms + # shouldn't) keeps the default index 0 rather than aborting setup. try: multicast_index = sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF) - except OSError: - # Windows rejects reading IPV6_MULTICAST_IF (WSAEINVAL); the - # default index 0 set above is kept there. On other platforms this - # read does not fail, so re-raise rather than mask a genuine error - # into a wrong-interface group leave. - if sys.platform != "win32": - raise + except OSError as exc: + log.debug("Unable to read IPV6_MULTICAST_IF, using default index 0: %s", exc) return _WrappedTransport( transport=transport, is_ipv6=is_ipv6, diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 04ab97f1..334d8c88 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -76,7 +76,11 @@ def test_make_wrapped_transport_reads_v6_multicast_index() -> None: def test_make_wrapped_transport_unreadable_multicast_index() -> None: - """A socket that rejects reading IPV6_MULTICAST_IF falls back to index 0.""" + """A socket that rejects reading IPV6_MULTICAST_IF falls back to index 0. + + This runs on the startup/connection path, so a read failure must not abort + setup; it degrades to the default index (only a future group leave cares). + """ sock = Mock() sock.family = socket.AF_INET6 sock.fileno.return_value = 0 @@ -84,12 +88,7 @@ def test_make_wrapped_transport_unreadable_multicast_index() -> None: sock.getsockopt.side_effect = OSError transport = Mock() transport.get_extra_info.return_value = sock - # Windows: expected (WSAEINVAL), silent fallback to the default index. - with patch("zeroconf._transport.sys.platform", "win32"): - assert make_wrapped_transport(transport).multicast_index == 0 - # Other platforms: the read does not fail there, so an error is re-raised. - with patch("zeroconf._transport.sys.platform", "linux"), pytest.raises(OSError): - make_wrapped_transport(transport) + assert make_wrapped_transport(transport).multicast_index == 0 def test_listen_socket_supports_family() -> None: @@ -108,14 +107,10 @@ def test_listen_socket_supports_family() -> None: assert _listen_socket_supports(v6_sock, "1.2.3.4") is True v6_sock.getsockopt.return_value = 1 # IPV6_V6ONLY on -> v6-only assert _listen_socket_supports(v6_sock, "1.2.3.4") is False - # An unreadable option is expected only on Windows (WSAEINVAL); treat as - # supported there so it can't drive a rebuild loop, but surface a genuine - # read failure on other platforms rather than mask an unreceivable family. + # An unreadable option degrades to "assume dual-stack" rather than aborting + # the rescan; at worst it skips a rebuild the next reconcile re-evaluates. v6_sock.getsockopt.side_effect = OSError - with patch("zeroconf._engine.sys.platform", "win32"): - assert _listen_socket_supports(v6_sock, "1.2.3.4") is True - with patch("zeroconf._engine.sys.platform", "linux"), pytest.raises(OSError): - _listen_socket_supports(v6_sock, "1.2.3.4") + assert _listen_socket_supports(v6_sock, "1.2.3.4") is True @pytest.mark.asyncio From c93f53763f70cd1325565cb0d6a75eb7d613c384 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 10:14:06 -0500 Subject: [PATCH 18/23] fix: surface an unreadable IPV6_V6ONLY at warning, document best-effort 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. --- src/zeroconf/_core.py | 8 +++++--- src/zeroconf/_engine.py | 15 ++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index effdaee2..44bf689d 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -454,9 +454,11 @@ async def async_update_interfaces( interface set is unchanged this is a no-op (no sockets touched, nothing re-announced). The listen socket is rebuilt if the new set needs a different address family; unicast mode is fixed at - construction. Concurrent calls are serialized. Re-announcement is - best-effort: a registration that fails to re-announce is logged, not - raised, so one failure cannot block the others. + construction. Concurrent calls are serialized. Bringing up interfaces + is best-effort: a requested interface that fails to bind, or fails to + re-join after a rebuild, is logged rather than raised, and likewise a + registration that fails to re-announce is logged so one failure cannot + block the others. """ # Resolve against the retained config but only commit it after the # engine reconcile succeeds, so a failed reconcile leaves the stored diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 2cdc1840..c5e75af7 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -80,11 +80,13 @@ def _listen_socket_supports( try: supported = not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) except OSError as exc: - # Reading IPV6_V6ONLY can fail (Windows rejects it on some sockets; - # other platforms shouldn't). Assume dual-stack so a read failure can't - # abort the rescan; at worst this skips a rebuild the next reconcile - # re-evaluates, consistent with make_wrapped_transport's fallback. - log.debug("Unable to read IPV6_V6ONLY, assuming dual-stack: %s", exc) + # Reading IPV6_V6ONLY essentially never fails on a valid AF_INET6 + # socket. Assume dual-stack rather than abort the rescan; returning + # False instead could loop rebuilds if the rebuilt socket's read also + # fails. Log at warning, not debug, because if the socket really were + # v6-only this skips a needed rebuild and leaves an added IPv4 family + # unreceivable, which is worth surfacing. + log.warning("Unable to read IPV6_V6ONLY, assuming dual-stack: %s", exc) return supported @@ -255,6 +257,9 @@ async def async_update_interfaces( # The shared listen / dual-use socket is not a per-interface # sender; leaving the group or closing it would break receive. continue + # After a rebuild, listen_socket is the new socket, which this gone + # interface never joined (its membership died with the old socket); + # the leave is then a benign no-op that drop_multicast_member swallows. self._async_close_sender(wrapped, listen_socket) added = False From 423f8752e97209807903b3a070afb806f703495b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 10:45:09 -0500 Subject: [PATCH 19/23] fix: make rescan resilient to a transient empty set and per-interface 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. --- src/zeroconf/_engine.py | 20 ++++++++++++---- src/zeroconf/_transport.py | 9 ++++++- tests/test_interface_update.py | 43 +++++++++++++++++++++++++++------- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index c5e75af7..20b5559e 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -211,7 +211,16 @@ async def async_update_interfaces( added, so the caller can skip re-announcing when nothing appeared. """ assert self.loop is not None - normalized = normalize_interface_choice(interfaces, ip_version) + try: + normalized = normalize_interface_choice(interfaces, ip_version) + except RuntimeError as exc: + # An All/Default instance can transiently resolve to zero addresses + # during adapter churn, where normalize_interface_choice raises + # instead of returning an empty set. Treat that as a logged no-op so + # a momentary down state doesn't crash a caller's adapter-change + # handler (best-effort contract); the next rescan reconciles. + log.warning("Skipping interface update; no interfaces available: %s", exc) + return False desired = {_interface_key(interface): interface for interface in normalized} current = {wrapped.interface_key: wrapped for wrapped in self.senders} listen_transport = self._listen_transport @@ -291,13 +300,16 @@ async def _async_add_interface( return False try: await self._async_wrap_socket(respond_socket, is_sender=True) - except Exception: + except Exception as exc: # Endpoint creation failed after the join/socket succeeded; roll - # this interface back so it leaves no dangling group membership. + # this interface back so it leaves no dangling group membership, then + # log and skip rather than abort the whole reconcile so the other + # interfaces still come up (best-effort bring-up). respond_socket.close() if listen_socket is not None: drop_multicast_member(listen_socket, interface) - raise + self.zc.log_warning_once(f"Interface {interface!r} not added: {exc}") + return False return True def _async_remove_transport(self, transport: asyncio.DatagramTransport) -> None: diff --git a/src/zeroconf/_transport.py b/src/zeroconf/_transport.py index 54bafade..b67ca73e 100644 --- a/src/zeroconf/_transport.py +++ b/src/zeroconf/_transport.py @@ -23,7 +23,9 @@ from __future__ import annotations import asyncio +import logging import socket +import sys from typing import cast from ._logger import log @@ -111,7 +113,12 @@ def make_wrapped_transport(transport: asyncio.DatagramTransport) -> _WrappedTran try: multicast_index = sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF) except OSError as exc: - log.debug("Unable to read IPV6_MULTICAST_IF, using default index 0: %s", exc) + # Windows rejects this read (WSAEINVAL) for every v6 socket, so it is + # expected and benign there; keep it at debug. Elsewhere a failure is + # unexpected and means a later group leave uses the wrong index (a + # benign no-op that leaks the membership), so surface it at warning. + level = logging.DEBUG if sys.platform == "win32" else logging.WARNING + log.log(level, "Unable to read IPV6_MULTICAST_IF, using default index 0: %s", exc) return _WrappedTransport( transport=transport, is_ipv6=is_ipv6, diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 334d8c88..53e27aa4 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -10,7 +10,7 @@ import pytest -from zeroconf import IPVersion, ServiceInfo, Zeroconf, _engine, _listener +from zeroconf import InterfaceChoice, IPVersion, ServiceInfo, Zeroconf, _engine, _listener from zeroconf._engine import _interface_key, _listen_socket_supports from zeroconf._transport import _strip_zone, _WrappedTransport, make_wrapped_transport from zeroconf.asyncio import AsyncZeroconf @@ -432,6 +432,34 @@ async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: assert engine.senders[0].interface_key == ("10.0.0.9", 0) +@pytest.mark.asyncio +async def test_update_interfaces_transient_empty_set_is_noop( + aiozc_loopback: AsyncZeroconf, caplog: pytest.LogCaptureFixture +) -> None: + """An All instance that transiently resolves to zero interfaces logs and no-ops.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + engine = zc.engine + before = (len(engine.senders), len(engine.readers), len(engine.protocols)) + + # normalize_interface_choice raises for an All instance with no addresses + # (adapter churn); the rescan must not crash the caller's handler. + with ( + patch.object( + _engine, + "normalize_interface_choice", + side_effect=RuntimeError("No interfaces to listen on"), + ), + caplog.at_level(logging.WARNING), + ): + added = await engine.async_update_interfaces(InterfaceChoice.All, IPVersion.All, False) + + assert added is False + assert "Skipping interface update; no interfaces available" in caplog.text + # Current sockets are left intact rather than torn down. + assert (len(engine.senders), len(engine.readers), len(engine.protocols)) == before + + @pytest.mark.asyncio async def test_update_interfaces_add_failure_adds_no_sender(aiozc_loopback: AsyncZeroconf) -> None: """An interface that fails to come up adds no responder socket.""" @@ -450,7 +478,7 @@ async def test_update_interfaces_add_failure_adds_no_sender(aiozc_loopback: Asyn async def test_update_interfaces_rolls_back_membership_on_wrap_failure( aiozc_loopback: AsyncZeroconf, ) -> None: - """If endpoint creation raises, the interface's join and socket are rolled back.""" + """Endpoint creation failing rolls the interface back and is skipped, not raised.""" zc = aiozc_loopback.zeroconf await zc.async_wait_for_start() await aiozc_loopback.async_update_interfaces([]) @@ -462,15 +490,14 @@ async def test_update_interfaces_rolls_back_membership_on_wrap_failure( patch.object(_engine, "add_interface", return_value=fake_socket), patch.object(_engine, "drop_multicast_member") as mock_drop, patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))), - pytest.raises(OSError), ): + # Best-effort: the failure is logged and skipped, not propagated. await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) - # The just-joined membership was dropped, the socket closed, and the - # failed reconcile left the retained config unchanged. + # The just-joined membership was dropped, the socket closed, no sender added. mock_drop.assert_called_once() fake_socket.close.assert_called_once() - assert zc._interfaces == [] + assert zc.engine.senders == [] @pytest.mark.asyncio @@ -484,10 +511,10 @@ async def test_add_interface_rollback_without_listen_socket(aiozc_loopback: Asyn patch.object(_engine, "add_interface", return_value=fake_socket), patch.object(_engine, "drop_multicast_member") as mock_drop, patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))), - pytest.raises(OSError), ): - await engine._async_add_interface("127.0.0.1", None, False) + added = await engine._async_add_interface("127.0.0.1", None, False) + assert added is False fake_socket.close.assert_called_once() mock_drop.assert_not_called() From 55ca5eadb9eaca810d7cf2f61178da2ee509a541 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 11:18:56 -0500 Subject: [PATCH 20/23] fix: only swallow OSError when a per-interface endpoint fails 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. --- src/zeroconf/_engine.py | 13 +++++++++---- tests/test_interface_update.py | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 20b5559e..7dda4135 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -301,13 +301,18 @@ async def _async_add_interface( try: await self._async_wrap_socket(respond_socket, is_sender=True) except Exception as exc: - # Endpoint creation failed after the join/socket succeeded; roll - # this interface back so it leaves no dangling group membership, then - # log and skip rather than abort the whole reconcile so the other - # interfaces still come up (best-effort bring-up). + # Roll back the socket + group join on any failure so nothing is left + # dangling. respond_socket.close() if listen_socket is not None: drop_multicast_member(listen_socket, interface) + if not isinstance(exc, OSError): + # Only an expected socket-level failure is best-effort; a real + # bug (e.g. TypeError) must propagate rather than be downgraded + # to a one-time "interface not added" warning. + raise + # Log and skip rather than abort the whole reconcile so the other + # interfaces still come up (best-effort bring-up). self.zc.log_warning_once(f"Interface {interface!r} not added: {exc}") return False return True diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 53e27aa4..32ee77f3 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -519,6 +519,27 @@ async def test_add_interface_rollback_without_listen_socket(aiozc_loopback: Asyn mock_drop.assert_not_called() +@pytest.mark.asyncio +async def test_add_interface_propagates_non_oserror(aiozc_loopback: AsyncZeroconf) -> None: + """A non-OSError (a real bug) propagates after rollback, not downgraded to a warning.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + + fake_socket = Mock() + listen_socket = Mock() + with ( + patch.object(_engine, "add_interface", return_value=fake_socket), + patch.object(_engine, "drop_multicast_member") as mock_drop, + patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=TypeError("bug"))), + pytest.raises(TypeError), + ): + await engine._async_add_interface("127.0.0.1", listen_socket, False) + + # Rolled back (socket closed, membership dropped) even though it propagates. + fake_socket.close.assert_called_once() + mock_drop.assert_called_once() + + @pytest.mark.asyncio async def test_update_interfaces_keeps_dual_use_listen_socket(aiozc_loopback: AsyncZeroconf) -> None: """A dual-use sender (the listen socket itself) is never torn down on rescan.""" From f0090e4665c5c685aaa5e304058bb1042f72266f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 12:00:15 -0500 Subject: [PATCH 21/23] fix: don't degrade or crash on a listen-socket rebuild failure 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. --- src/zeroconf/_engine.py | 27 ++++++++++++------- tests/test_interface_update.py | 49 +++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 7dda4135..c68c1521 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -237,25 +237,32 @@ async def async_update_interfaces( # A Default single-family instance shares the listen socket as its only # sender (the dual-use socket). Moving it to an explicit interface set - # abandons that optimization: demote the socket so it stops responding - # (otherwise it would double every announcement on the overlapping - # interface) and rebuild it as a pure listener (its existing group - # memberships would otherwise collide with the new per-interface joins). - # Once demoted it no longer counts as a per-interface sender, so the - # interface it served gets a fresh responder like any other. The no-arg - # refresh of a Default instance leaves desired == {its interface} and so - # neither demotes nor rebuilds. + # abandons that optimization: rebuild it as a pure listener (its existing + # group memberships would otherwise collide with the new per-interface + # joins), which also stops it responding so it can't double-announce on + # the overlapping interface. Drop it from the diff's view so the desired + # interfaces are added fresh; the actual sender removal is done by the + # rebuild once it succeeds (so a failed rebuild leaves senders intact). + # The no-arg refresh of a Default instance leaves desired == {its + # interface} and so neither converts nor rebuilds. if listen_transport is not None and any( wrapped.transport is listen_transport.transport for wrapped in self.senders ): listen_key = listen_transport.interface_key if any(key != listen_key for key in desired): - self.senders = _without_transport(self.senders, listen_transport.transport) current.pop(listen_key, None) needs_rebuild = True if needs_rebuild: - await self._async_rebuild_listen_socket(apple_p2p, desired, current) + try: + await self._async_rebuild_listen_socket(apple_p2p, desired, current) + except (OSError, RuntimeError) as exc: + # A fresh wildcard bind / endpoint creation can transiently fail + # during adapter churn. The rebuild raises before tearing down + # the old listen socket, so state is unchanged; log and no-op + # rather than crash the caller's handler (best-effort contract). + log.warning("Skipping interface update; listen socket rebuild failed: %s", exc) + return False listen_transport = self._listen_transport listen_socket = listen_transport.sock if listen_transport is not None else None diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 32ee77f3..7cf95575 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -704,16 +704,50 @@ async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport: @pytest.mark.asyncio -async def test_update_interfaces_rebuild_failure_raises(aiozc_loopback: AsyncZeroconf) -> None: - """If the replacement listen socket can't be created, the rebuild raises.""" +async def test_update_interfaces_rebuild_failure_is_noop( + aiozc_loopback: AsyncZeroconf, caplog: pytest.LogCaptureFixture +) -> None: + """If the replacement listen socket can't be created, the rescan logs and no-ops.""" engine = aiozc_loopback.zeroconf.engine await aiozc_loopback.zeroconf.async_wait_for_start() + old_listen = engine._listen_transport + before = (list(engine.senders), list(engine.readers)) with ( patch.object(_engine, "normalize_interface_choice", return_value=[(("fe80::1", 0, 0), 1)]), patch.object(_engine, "new_listen_socket", return_value=None), - pytest.raises(RuntimeError, match="listen socket"), + caplog.at_level(logging.WARNING), ): - await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) + added = await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) + + # Best-effort: no raise into the caller, state left untouched. + assert added is False + assert "listen socket rebuild failed" in caplog.text + assert engine._listen_transport is old_listen + assert (list(engine.senders), list(engine.readers)) == before + + +@pytest.mark.asyncio +async def test_update_interfaces_default_rebuild_failure_keeps_dual_use( + aiozc_loopback: AsyncZeroconf, +) -> None: + """A failed rebuild during a dual-use conversion leaves the dual-use sender intact.""" + engine = aiozc_loopback.zeroconf.engine + await aiozc_loopback.zeroconf.async_wait_for_start() + listen = engine._listen_transport + assert listen is not None + # Simulate a Default single-family instance: the listen socket is the sole sender. + engine.senders = [listen] + with ( + patch.object(_engine, "normalize_interface_choice", return_value=["192.168.1.5"]), + patch.object(_engine, "new_listen_socket", return_value=None), + ): + added = await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False) + + # The dual-use socket was not demoted before the (failed) rebuild, so the + # instance still responds on its interface rather than going silent. + assert added is False + assert engine.senders == [listen] + assert engine._listen_transport is listen @pytest.mark.asyncio @@ -732,11 +766,12 @@ async def test_update_interfaces_rebuild_closes_socket_on_wrap_failure( patch.object(_engine, "new_listen_socket", return_value=new_listen_sock), patch.object(_engine, "add_multicast_member", return_value=True), patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))), - pytest.raises(OSError), ): - await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) + added = await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False) - # The unadopted socket was closed, and the old listen socket is untouched. + # Best-effort no-op: the unadopted socket was closed (not leaked) and the + # old listen socket is untouched. + assert added is False new_listen_sock.close.assert_called_once() assert engine._listen_transport is old_listen From c2df9023b4ad3aa5eed7022e9e9d3c78e6e45626 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 12:12:34 -0500 Subject: [PATCH 22/23] fix: don't drop a cancelled re-announce; document the apple_p2p raise 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. --- src/zeroconf/_core.py | 10 ++++++++-- src/zeroconf/asyncio.py | 2 +- tests/test_interface_update.py | 25 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 44bf689d..209e1dec 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -425,7 +425,8 @@ def update_interfaces( While it is not expected during normal operation, this function may raise EventLoopBlocked if the underlying - call to `async_update_interfaces` cannot be completed. + call to `async_update_interfaces` cannot be completed. Raises + RuntimeError if apple_p2p is set on a non-Apple platform. """ assert self.loop is not None # Unlike register/update, the re-announce is awaited inline (to log @@ -458,7 +459,8 @@ async def async_update_interfaces( is best-effort: a requested interface that fails to bind, or fails to re-join after a rebuild, is logged rather than raised, and likewise a registration that fails to re-announce is logged so one failure cannot - block the others. + block the others. Raises RuntimeError if apple_p2p is set on a non-Apple + platform (input validation, matching the constructor). """ # Resolve against the retained config but only commit it after the # engine reconcile succeeds, so a failed reconcile leaves the stored @@ -493,6 +495,10 @@ async def async_update_interfaces( for info, result in zip(infos, results, strict=True): if isinstance(result, Exception): log.warning("Error re-announcing %s after interface update: %s", info.name, result) + elif isinstance(result, BaseException): + # gather(return_exceptions=True) also captures BaseExceptions + # such as CancelledError; don't swallow a cancellation/interrupt. + raise result async def async_get_service_info( self, diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index ed1bd60c..2061243d 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -236,7 +236,7 @@ async def async_update_interfaces( interfaces that disappeared, and re-announces existing registrations on the resulting senders. ``interfaces``, ``ip_version`` and ``apple_p2p`` each default to the construction-time - value. + value. Raises RuntimeError if apple_p2p is set on a non-Apple platform. """ await self.zeroconf.async_update_interfaces(interfaces, ip_version, apple_p2p) diff --git a/tests/test_interface_update.py b/tests/test_interface_update.py index 7cf95575..380f98b9 100644 --- a/tests/test_interface_update.py +++ b/tests/test_interface_update.py @@ -400,6 +400,31 @@ async def broadcast(info: ServiceInfo, *args: object) -> None: assert infos[1].name not in caplog.text +@pytest.mark.asyncio +async def test_update_interfaces_reannounce_cancellation_propagates(aiozc_loopback: AsyncZeroconf) -> None: + """A cancelled re-announce propagates rather than being silently dropped as a non-Exception.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + info = ServiceInfo( + "_test._tcp.local.", + "Test._test._tcp.local.", + addresses=[b"\x7f\x00\x00\x01"], + port=80, + server="test.local.", + ) + await aiozc_loopback.async_register_service(info) + await aiozc_loopback.async_update_interfaces([]) + await asyncio.sleep(0) + + with ( + patch.object( + zc, "_async_broadcast_service", new_callable=AsyncMock, side_effect=asyncio.CancelledError + ), + pytest.raises(asyncio.CancelledError), + ): + await aiozc_loopback.async_update_interfaces(["127.0.0.1"]) + + @pytest.mark.asyncio async def test_update_interfaces_ip_change_in_one_rescan(aiozc_loopback: AsyncZeroconf) -> None: """An interface whose address changes is removed and re-added in a single rescan.""" From ca0e061a2ec293d2445be1cbc8d3c8deb3db35a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 18:27:33 -0500 Subject: [PATCH 23/23] feat: add opt-in periodic interface-change monitor --- src/zeroconf/_core.py | 25 ++++ src/zeroconf/_utils/interface_monitor.py | 71 +++++++++++ src/zeroconf/asyncio.py | 16 +++ tests/test_interface_monitor.py | 154 +++++++++++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 src/zeroconf/_utils/interface_monitor.py create mode 100644 tests/test_interface_monitor.py diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 209e1dec..bdce2809 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -59,6 +59,7 @@ wait_for_future_set_or_timeout, wait_future_or_timeout, ) +from ._utils.interface_monitor import _DEFAULT_INTERFACE_MONITOR_INTERVAL, InterfaceMonitor from ._utils.name import service_type_name from ._utils.net import ( InterfaceChoice, @@ -225,6 +226,7 @@ def __init__( # Serializes async_update_interfaces so overlapping calls (a bursty # adapter-change source) don't diff against a stale sender snapshot. self._interface_update_lock = asyncio.Lock() + self._interface_monitor: InterfaceMonitor | None = None self.loop: asyncio.AbstractEventLoop | None = None self._loop_thread: threading.Thread | None = None @@ -500,6 +502,28 @@ async def async_update_interfaces( # such as CancelledError; don't swallow a cancellation/interrupt. raise result + async def async_start_interface_monitor( + self, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL + ) -> None: + """Start an opt-in poller that rescans interfaces when adapters change. + + Interface change detection is platform specific; by default zeroconf + leaves it to the consumer. This polls every ``interval`` seconds and + calls `async_update_interfaces` when the address set changes. It is a + no-op if the monitor is already running. + """ + await self.async_wait_for_start() + if self._interface_monitor is None: + self._interface_monitor = InterfaceMonitor(self, interval) + self._interface_monitor.start() + + async def async_stop_interface_monitor(self) -> None: + """Stop the interface monitor if it is running.""" + monitor = self._interface_monitor + if monitor is not None: + self._interface_monitor = None + await monitor.async_stop() + async def async_get_service_info( self, type_: str, @@ -837,6 +861,7 @@ async def _async_close(self) -> None: before calling this function """ self._close() + await self.async_stop_interface_monitor() await self.engine._async_close() # pylint: disable=protected-access self._shutdown_threads() diff --git a/src/zeroconf/_utils/interface_monitor.py b/src/zeroconf/_utils/interface_monitor.py new file mode 100644 index 00000000..50cb0d24 --- /dev/null +++ b/src/zeroconf/_utils/interface_monitor.py @@ -0,0 +1,71 @@ +"""Optional periodic interface-change monitor. + +Interface change detection is platform specific and is left to the consumer +by default. This convenience monitor polls ``ifaddr.get_adapters`` and calls +``Zeroconf.async_update_interfaces`` when the set of interface addresses +changes, so a consumer that has no native change signal can still reconcile +sockets without restarting the instance. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import TYPE_CHECKING + +import ifaddr + +from .._logger import log + +if TYPE_CHECKING: + from .._core import Zeroconf + +_DEFAULT_INTERFACE_MONITOR_INTERVAL = 5.0 # seconds + + +def _adapter_snapshot() -> frozenset[tuple[int | None, str]]: + """Return a hashable snapshot of every adapter index and address.""" + return frozenset((adapter.index, str(ip.ip)) for adapter in ifaddr.get_adapters() for ip in adapter.ips) + + +class InterfaceMonitor: + """Poll for adapter changes and rescan interfaces when they change.""" + + __slots__ = ("_interval", "_snapshot", "_task", "_zc") + + def __init__(self, zc: Zeroconf, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL) -> None: + self._zc = zc + self._interval = interval + self._snapshot = _adapter_snapshot() + self._task: asyncio.Task[None] | None = None + + def start(self) -> None: + """Start the poll task on the running loop.""" + assert self._zc.loop is not None + if self._task is None: + self._task = self._zc.loop.create_task(self._async_run()) + + async def async_stop(self) -> None: + """Cancel the poll task and wait for it to finish.""" + task = self._task + if task is None: + return + self._task = None + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + async def _async_run(self) -> None: + """Rescan interfaces whenever the adapter snapshot changes.""" + while True: + await asyncio.sleep(self._interval) + snapshot = _adapter_snapshot() + if snapshot == self._snapshot: + continue + self._snapshot = snapshot + try: + await self._zc.async_update_interfaces() + except Exception: + # A transient failure must not kill the monitor; the next + # change still triggers a rescan. + log.exception("Interface rescan failed") diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 2061243d..0d71b27d 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -34,6 +34,7 @@ from ._services.browser import _ServiceBrowserBase from ._services.info import AsyncServiceInfo, ServiceInfo from ._services.types import ZeroconfServiceTypes +from ._utils.interface_monitor import _DEFAULT_INTERFACE_MONITOR_INTERVAL from ._utils.net import InterfaceChoice, InterfacesType, IPVersion from .const import _BROWSER_TIME, _MDNS_PORT, _SERVICE_TYPE_ENUMERATION_NAME @@ -240,6 +241,21 @@ async def async_update_interfaces( """ await self.zeroconf.async_update_interfaces(interfaces, ip_version, apple_p2p) + async def async_start_interface_monitor( + self, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL + ) -> None: + """Start an opt-in poller that rescans interfaces when adapters change. + + Interface change detection is platform specific; by default zeroconf + leaves it to the consumer. This polls every ``interval`` seconds and + reconciles the sockets in use when the address set changes. + """ + await self.zeroconf.async_start_interface_monitor(interval) + + async def async_stop_interface_monitor(self) -> None: + """Stop the interface monitor if it is running.""" + await self.zeroconf.async_stop_interface_monitor() + async def async_close(self) -> None: """Ends the background threads, and prevent this instance from servicing further queries.""" diff --git a/tests/test_interface_monitor.py b/tests/test_interface_monitor.py new file mode 100644 index 00000000..2905c144 --- /dev/null +++ b/tests/test_interface_monitor.py @@ -0,0 +1,154 @@ +"""Unit tests for the opt-in periodic interface-change monitor.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from unittest.mock import Mock, patch + +import pytest + +from zeroconf._utils import interface_monitor as im +from zeroconf._utils.interface_monitor import InterfaceMonitor +from zeroconf.asyncio import AsyncZeroconf + + +def _snapshot_cycler(snapshots: list[frozenset]) -> Callable[[], frozenset]: + """Return the next snapshot each call, sticking on the last one.""" + it = iter(snapshots) + + def _next() -> frozenset: + try: + return next(it) + except StopIteration: + return snapshots[-1] + + return _next + + +def test_adapter_snapshot() -> None: + adapter = Mock() + adapter.index = 1 + ip = Mock() + ip.ip = "192.168.1.5" + adapter.ips = [ip] + with patch.object(im.ifaddr, "get_adapters", return_value=[adapter]): + assert im._adapter_snapshot() == frozenset({(1, "192.168.1.5")}) + + +@pytest.mark.asyncio +async def test_monitor_rescans_on_change(aiozc_loopback: AsyncZeroconf) -> None: + """A changed adapter snapshot triggers a rescan.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + updated = asyncio.Event() + + async def _fake_update() -> None: + updated.set() + + with ( + patch.object( + im, "_adapter_snapshot", side_effect=_snapshot_cycler([frozenset({"a"}), frozenset({"b"})]) + ), + patch.object(zc, "async_update_interfaces", side_effect=_fake_update), + ): + await aiozc_loopback.async_start_interface_monitor(interval=0.001) + await asyncio.wait_for(updated.wait(), timeout=1.0) + await aiozc_loopback.async_stop_interface_monitor() + + +@pytest.mark.asyncio +async def test_monitor_no_rescan_when_unchanged(aiozc_loopback: AsyncZeroconf) -> None: + """An unchanged snapshot does not trigger a rescan.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + with ( + patch.object(im, "_adapter_snapshot", return_value=frozenset({"same"})), + patch.object(zc, "async_update_interfaces") as mock_update, + ): + await aiozc_loopback.async_start_interface_monitor(interval=0.001) + await asyncio.sleep(0.02) + await aiozc_loopback.async_stop_interface_monitor() + mock_update.assert_not_called() + + +@pytest.mark.asyncio +async def test_monitor_survives_rescan_error(aiozc_loopback: AsyncZeroconf) -> None: + """A failed rescan is logged and the monitor keeps running.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + calls = [] + + async def _boom() -> None: + calls.append(1) + raise RuntimeError("boom") + + snapshots = [frozenset({"a"}), frozenset({"b"}), frozenset({"c"})] + with ( + patch.object(im, "_adapter_snapshot", side_effect=_snapshot_cycler(snapshots)), + patch.object(zc, "async_update_interfaces", side_effect=_boom), + ): + await aiozc_loopback.async_start_interface_monitor(interval=0.001) + await asyncio.sleep(0.05) + await aiozc_loopback.async_stop_interface_monitor() + assert len(calls) >= 2 + + +@pytest.mark.asyncio +async def test_start_interface_monitor_idempotent(aiozc_loopback: AsyncZeroconf) -> None: + """Starting an already-running monitor is a no-op.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + with patch.object(im, "_adapter_snapshot", return_value=frozenset()): + await aiozc_loopback.async_start_interface_monitor(interval=10) + monitor = zc._interface_monitor + assert monitor is not None + task = monitor._task + await aiozc_loopback.async_start_interface_monitor(interval=10) + assert zc._interface_monitor is monitor + assert monitor._task is task + await aiozc_loopback.async_stop_interface_monitor() + + +@pytest.mark.asyncio +async def test_monitor_start_idempotent(aiozc_loopback: AsyncZeroconf) -> None: + """InterfaceMonitor.start is a no-op when a task is already scheduled.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + with patch.object(im, "_adapter_snapshot", return_value=frozenset()): + monitor = InterfaceMonitor(zc, interval=10) + monitor.start() + task = monitor._task + monitor.start() + assert monitor._task is task + await monitor.async_stop() + + +@pytest.mark.asyncio +async def test_monitor_stop_without_start(aiozc_loopback: AsyncZeroconf) -> None: + """Stopping a monitor that never started is a no-op.""" + zc = aiozc_loopback.zeroconf + await zc.async_wait_for_start() + with patch.object(im, "_adapter_snapshot", return_value=frozenset()): + monitor = InterfaceMonitor(zc) + await monitor.async_stop() + + +@pytest.mark.asyncio +async def test_core_stop_interface_monitor_when_none(aiozc_loopback: AsyncZeroconf) -> None: + """Stopping the monitor when none is running is a no-op.""" + await aiozc_loopback.zeroconf.async_wait_for_start() + await aiozc_loopback.async_stop_interface_monitor() + + +@pytest.mark.asyncio +async def test_monitor_stopped_on_close() -> None: + """async_close stops a running interface monitor.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + zc = aiozc.zeroconf + await zc.async_wait_for_start() + with patch.object(im, "_adapter_snapshot", return_value=frozenset()): + await aiozc.async_start_interface_monitor(interval=10) + assert zc._interface_monitor is not None + await aiozc.async_close() + assert zc._interface_monitor is None