Files
StarPilot/openpilot/starpilot/galaxy_bluetooth/vendor/zeroconf/_engine.py
T

457 lines
20 KiB
Python

"""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