"""A pure python implementation of multicast DNS service discovery. Licensed under LGPL-2.1-or-later; see COPYING for details. This file is part of a continuously modified work; modification dates are recorded in the project's git history. """ from __future__ import annotations import asyncio import itertools import socket 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 ( InterfacesType, IPVersion, add_interface, add_multicast_member, drop_multicast_member, new_listen_socket, normalize_interface_choice, ) from ._utils.time import current_time_millis from .const import _CACHE_CLEANUP_INTERVAL if TYPE_CHECKING: from ._core import Zeroconf from ._listener import AsyncListener 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) 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. supported = True try: supported = not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) except OSError as 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 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.""" __slots__ = ( "_cleanup_timer", "_listen_socket", "_listen_transport", "_respond_sockets", "_setup_task", "loop", "protocols", "readers", "running_future", "senders", "zc", ) def __init__( self, zeroconf: Zeroconf, listen_socket: socket.socket | None, respond_sockets: list[socket.socket], ) -> None: self.loop: asyncio.AbstractEventLoop | None = None self.zc = zeroconf self.protocols: list[AsyncListener] = [] self.readers: list[_WrappedTransport] = [] 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 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. 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 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 listen_socket = listen_transport.sock if listen_transport is not None else None # 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: 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): current.pop(listen_key, None) needs_rebuild = True if needs_rebuild: 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 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 # 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 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 def close(self) -> None: """Close from sync context. While it is not expected during normal operation, this function may raise EventLoopBlocked if the underlying call to `_async_close` cannot be completed. """ assert self.loop is not None # Guard against Zeroconf.close() being called from the eventloop if get_running_loop() == self.loop: self._async_shutdown() return if not self.loop.is_running(): return run_coro_with_timeout(self._async_close(), self.loop, _CLOSE_TIMEOUT) def setup( self, loop: asyncio.AbstractEventLoop, loop_thread_ready: threading.Event | None, ) -> None: """Set up the instance.""" self.loop = loop self.running_future = loop.create_future() self._setup_task = self.loop.create_task(self._async_setup(loop_thread_ready)) 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. """ # 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. respond_socket = add_interface(listen_socket, interface, apple_p2p=apple_p2p, unicast=self.zc.unicast) if respond_socket is None: self.zc.log_warning_once(f"Interface {interface!r} not added") return False try: await self._async_wrap_socket(respond_socket, is_sender=True) except Exception as exc: # 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 def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" now = current_time_millis() self.zc.question_history.async_expire(now) self.zc.record_manager.async_updates( now, [RecordUpdate(record, record) for record in self.zc.cache.async_expire(now)], ) self.zc.record_manager.async_updates_complete(False) self._async_schedule_next_cache_cleanup() async def _async_close(self) -> None: """Cancel and wait for the cleanup task to finish.""" assert self._setup_task is not None # Swallow CancelledError only if the setup task itself was # cancelled (close-before-start); outer-task cancellation must # propagate. try: await self._setup_task except asyncio.CancelledError: if not self._setup_task.cancelled(): raise self._async_shutdown() await asyncio.sleep(0) # flush out any call soons if self._cleanup_timer is not None: self._cleanup_timer.cancel() 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_create_endpoints(self) -> None: """Create endpoints to send and receive.""" reader_sockets = [] sender_sockets = [] if self._listen_socket: reader_sockets.append(self._listen_socket) for s in self._respond_sockets: if s not in reader_sockets: reader_sockets.append(s) sender_sockets.append(s) for s in reader_sockets: reader = await self._async_wrap_socket(s, s in sender_sockets) # _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. self._listen_transport = reader self._listen_socket = None if s in self._respond_sockets: self._respond_sockets.remove(s) async def _async_rebuild_listen_socket( self, apple_p2p: bool, desired: dict[tuple[str, int], str | tuple[tuple[str, int, int], int]], current: dict[tuple[str, int], _WrappedTransport], ) -> None: """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, 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, and the old socket is closed (releasing its memberships). """ 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_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: for bind_address, interface in desired.items(): # 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 # 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 = new_reader old_transport = old_listen_transport.transport self._async_remove_transport(old_transport) old_transport.close() 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: # 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 = _without_transport(self.readers, transport) self.senders = _without_transport(self.senders, transport) def _async_schedule_next_cache_cleanup(self) -> None: """Schedule the next cache cleanup.""" loop = self.loop assert loop is not None self._cleanup_timer = loop.call_at(loop.time() + _CACHE_CLEANUP_INTERVAL, self._async_cache_cleanup) async def _async_setup(self, loop_thread_ready: threading.Event | None) -> None: """Set up the instance.""" self._async_schedule_next_cache_cleanup() await self._async_create_endpoints() assert self.running_future is not None if not self.running_future.done(): self.running_future.set_result(True) if loop_thread_ready: loop_thread_ready.set() def _async_shutdown(self) -> None: """Shutdown transports and sockets; safe to call repeatedly.""" assert self.running_future is not None assert self.loop is not None self.running_future = self.loop.create_future() # Cancel pending setup so it can't wrap fresh transports after # shutdown has started. if self._setup_task is not None and not self._setup_task.done(): self._setup_task.cancel() for wrapped_transport in itertools.chain(self.senders, self.readers): wrapped_transport.transport.close() # Anything still here was never adopted by a transport. if self._listen_socket is not None: self._listen_socket.close() self._listen_socket = None for s in self._respond_sockets: s.close() self._respond_sockets = [] 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