GeezerVison is showing github.com — feat: report whether async_update_interfaces brought every interface up · python-zeroconf/python-zeroconf@c52e18f
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: src/zeroconf/_core.py +30-21Lines changed: 30 additions & 21 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 14 | 14 | import threading | 15 | 15 | from collections.abc import Awaitable | 16 | 16 | from types import TracebackType | 17 | | -from typing import Self | | 17 | +from typing import Self, cast | 18 | 18 |
| 19 | 19 | from ._cache import DNSCache | 20 | 20 | from ._dns import DNSQuestion, DNSQuestionType | @@ -417,7 +417,7 @@ async def async_update_interfaces( | 417 | 417 | interfaces: InterfacesType | None = None, | 418 | 418 | ip_version: IPVersion | None = None, | 419 | 419 | apple_p2p: bool | None = None, | 420 | | - ) -> None: | | 420 | + ) -> bool: | 421 | 421 | """Rescan network interfaces and reconcile the sockets in use. | 422 | 422 |
| 423 | 423 | Adds sockets for interfaces that appeared, drops sockets for | @@ -432,8 +432,11 @@ async def async_update_interfaces( | 432 | 432 | is best-effort: a requested interface that fails to bind, or fails to | 433 | 433 | re-join after a rebuild, is logged rather than raised, and likewise a | 434 | 434 | registration that fails to re-announce is logged so one failure cannot | 435 | | - block the others. Raises RuntimeError if apple_p2p is set on a non-Apple | 436 | | - platform (input validation, matching the constructor). | | 435 | + block the others. Returns True if every requested interface is up, and | | 436 | + False if any is not. When no interface is available or the listen socket | | 437 | + cannot be rebuilt for a new address family, nothing is changed and the | | 438 | + previous configuration is kept. Raises RuntimeError if apple_p2p is | | 439 | + set on a non-Apple platform (input validation, matching the constructor). | 437 | 440 | """ | 438 | 441 | # Resolve against the retained config but only commit it after the | 439 | 442 | # engine reconcile succeeds, so a failed reconcile leaves the stored | @@ -449,14 +452,15 @@ async def async_update_interfaces( | 449 | 452 | # alone; the multi-second re-announce runs unlocked so a bursty | 450 | 453 | # adapter-change source isn't blocked behind it. | 451 | 454 | async with self._interface_update_lock: | 452 | | - added = await self.engine.async_update_interfaces(interfaces, ip_version, apple_p2p) | 453 | | - # Copy a mutable list so later caller mutation can't change the | 454 | | - # retained configuration. | 455 | | - self._interfaces = list(interfaces) if isinstance(interfaces, list) else interfaces | 456 | | - self._ip_version = ip_version | 457 | | - self._apple_p2p = apple_p2p | 458 | | - if not added: | 459 | | - return | | 455 | + outcome = await self.engine.async_update_interfaces(interfaces, ip_version, apple_p2p) | | 456 | + if outcome.applied: | | 457 | + # Copy a mutable list so later caller mutation can't change the | | 458 | + # retained configuration. | | 459 | + self._interfaces = list(interfaces) if isinstance(interfaces, list) else interfaces | | 460 | + self._ip_version = ip_version | | 461 | + self._apple_p2p = apple_p2p | | 462 | + if not outcome.added: | | 463 | + return outcome.complete | 460 | 464 | # Re-announce every registration; one broadcast failing must not mask | 461 | 465 | # the rest, so collect exceptions and log them individually, naming the | 462 | 466 | # service so a partial failure is actionable. | @@ -472,6 +476,7 @@ async def async_update_interfaces( | 472 | 476 | # gather(return_exceptions=True) also captures BaseExceptions | 473 | 477 | # such as CancelledError; don't swallow a cancellation/interrupt. | 474 | 478 | raise result | | 479 | + return outcome.complete | 475 | 480 |
| 476 | 481 | async def async_update_service(self, info: ServiceInfo) -> Awaitable: | 477 | 482 | """Publish updated records for an already registered service. | @@ -682,23 +687,27 @@ def update_interfaces( | 682 | 687 | interfaces: InterfacesType | None = None, | 683 | 688 | ip_version: IPVersion | None = None, | 684 | 689 | apple_p2p: bool | None = None, | 685 | | - ) -> None: | | 690 | + ) -> bool: | 686 | 691 | """Rescan network interfaces and reconcile the sockets in use. | 687 | 692 |
| 688 | | - While it is not expected during normal operation, | 689 | | - this function may raise EventLoopBlocked if the underlying | 690 | | - call to `async_update_interfaces` cannot be completed. Raises | 691 | | - RuntimeError if apple_p2p is set on a non-Apple platform. | | 693 | + Returns whether every requested interface is up, as | | 694 | + `async_update_interfaces` does. While it is not expected during | | 695 | + normal operation, this function may raise EventLoopBlocked if the | | 696 | + underlying call to `async_update_interfaces` cannot be completed. | | 697 | + Raises RuntimeError if apple_p2p is set on a non-Apple platform. | 692 | 698 | """ | 693 | 699 | assert self.loop is not None | 694 | 700 | # Unlike register/update, the re-announce is awaited inline (to log | 695 | 701 | # per-service failures), so the budget must cover the full announce | 696 | 702 | # window ((_REGISTER_BROADCASTS - 1) * _REGISTER_TIME) plus the reconcile | 697 | 703 | # and wait-for-start overhead; double the register budget for headroom. | 698 | | - run_coro_with_timeout( | 699 | | - self.async_update_interfaces(interfaces, ip_version, apple_p2p), | 700 | | - self.loop, | 701 | | - _REGISTER_TIME * _REGISTER_BROADCASTS * 2, | | 704 | + return cast( | | 705 | + bool, | | 706 | + run_coro_with_timeout( | | 707 | + self.async_update_interfaces(interfaces, ip_version, apple_p2p), | | 708 | + self.loop, | | 709 | + _REGISTER_TIME * _REGISTER_BROADCASTS * 2, | | 710 | + ), | 702 | 711 | ) | 703 | 712 |
| 704 | 713 | def update_service(self, info: ServiceInfo) -> None: | |
Collapse file Copy file name to clipboardExpand all lines: src/zeroconf/_engine.py +30-10Lines changed: 30 additions & 10 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 11 | 11 | import itertools | 12 | 12 | import socket | 13 | 13 | import threading | 14 | | -from typing import TYPE_CHECKING, cast | | 14 | +from typing import TYPE_CHECKING, NamedTuple, cast | 15 | 15 |
| 16 | 16 | from ._logger import log | 17 | 17 | from ._record_update import RecordUpdate | @@ -82,6 +82,17 @@ def _without_transport( | 82 | 82 | return [wrapped for wrapped in wrappers if wrapped.transport is not transport] | 83 | 83 |
| 84 | 84 |
| | 85 | +class InterfaceUpdateResult(NamedTuple): | | 86 | + """Outcome of reconciling the sockets to a requested interface set.""" | | 87 | + | | 88 | + applied: bool | | 89 | + added: bool | | 90 | + complete: bool | | 91 | + | | 92 | + | | 93 | +_NOT_APPLIED = InterfaceUpdateResult(applied=False, added=False, complete=False) | | 94 | + | | 95 | + | 85 | 96 | class AsyncEngine: | 86 | 97 | """An engine wraps sockets in the event loop.""" | 87 | 98 |
| @@ -122,16 +133,18 @@ async def async_update_interfaces( | 122 | 133 | interfaces: InterfacesType, | 123 | 134 | ip_version: IPVersion, | 124 | 135 | apple_p2p: bool, | 125 | | - ) -> bool: | | 136 | + ) -> InterfaceUpdateResult: | 126 | 137 | """Reconcile sender/reader sockets to the live interface set. | 127 | 138 |
| 128 | 139 | Adds a per-interface responder socket for each interface that | 129 | 140 | appeared and tears down the socket for each interface that | 130 | 141 | disappeared, diffing on the bound address. A Default single-family | 131 | 142 | instance's dual-use listen/responder socket is converted to a pure | 132 | 143 | listener when moving to an explicit set; otherwise the shared listen | 133 | | - socket is left intact. Returns whether any responder socket was | 134 | | - added, so the caller can skip re-announcing when nothing appeared. | | 144 | + socket is left intact. The result reports whether the reconcile ran | | 145 | + at all, whether any responder socket was added (so the caller can skip | | 146 | + re-announcing when nothing appeared), and whether every desired | | 147 | + interface ended up bound and joined. | 135 | 148 | """ | 136 | 149 | assert self.loop is not None | 137 | 150 | try: | @@ -143,7 +156,7 @@ async def async_update_interfaces( | 143 | 156 | # a momentary down state doesn't crash a caller's adapter-change | 144 | 157 | # handler (best-effort contract); the next rescan reconciles. | 145 | 158 | log.warning("Skipping interface update; no interfaces available: %s", exc) | 146 | | - return False | | 159 | + return _NOT_APPLIED | 147 | 160 | desired = {_interface_key(interface): interface for interface in normalized} | 148 | 161 | current = {wrapped.interface_key: wrapped for wrapped in self.senders} | 149 | 162 | listen_transport = self._listen_transport | @@ -176,16 +189,17 @@ async def async_update_interfaces( | 176 | 189 | current.pop(listen_key, None) | 177 | 190 | needs_rebuild = True | 178 | 191 |
| | 192 | + complete = True | 179 | 193 | if needs_rebuild: | 180 | 194 | try: | 181 | | - await self._async_rebuild_listen_socket(apple_p2p, desired, current) | | 195 | + complete = await self._async_rebuild_listen_socket(apple_p2p, desired, current) | 182 | 196 | except (OSError, RuntimeError) as exc: | 183 | 197 | # A fresh wildcard bind / endpoint creation can transiently fail | 184 | 198 | # during adapter churn. The rebuild raises before tearing down | 185 | 199 | # the old listen socket, so state is unchanged; log and no-op | 186 | 200 | # rather than crash the caller's handler (best-effort contract). | 187 | 201 | log.warning("Skipping interface update; listen socket rebuild failed: %s", exc) | 188 | | - return False | | 202 | + return _NOT_APPLIED | 189 | 203 | listen_transport = self._listen_transport | 190 | 204 | listen_socket = listen_transport.sock if listen_transport is not None else None | 191 | 205 |
| @@ -207,7 +221,9 @@ async def async_update_interfaces( | 207 | 221 | continue | 208 | 222 | if await self._async_add_interface(interface, listen_socket, apple_p2p): | 209 | 223 | added = True | 210 | | - return added | | 224 | + else: | | 225 | + complete = False | | 226 | + return InterfaceUpdateResult(applied=True, added=added, complete=complete) | 211 | 227 |
| 212 | 228 | def close(self) -> None: | 213 | 229 | """Close from sync context. | @@ -341,7 +357,7 @@ async def _async_rebuild_listen_socket( | 341 | 357 | apple_p2p: bool, | 342 | 358 | desired: dict[tuple[str, int], str | tuple[tuple[str, int, int], int]], | 343 | 359 | current: dict[tuple[str, int], _WrappedTransport], | 344 | | - ) -> None: | | 360 | + ) -> bool: | 345 | 361 | """Replace the listen socket with one whose family covers the desired set. | 346 | 362 |
| 347 | 363 | The listen socket's family is otherwise fixed at construction; this | @@ -351,7 +367,8 @@ async def _async_rebuild_listen_socket( | 351 | 367 | requested ip_version, which an explicit list can contradict) so it | 352 | 368 | always covers every desired interface and never needs an immediate | 353 | 369 | re-rebuild. Interfaces that are staying are re-joined on the new socket, | 354 | | - and the old socket is closed (releasing its memberships). | | 370 | + and the old socket is closed (releasing its memberships). Returns | | 371 | + whether every staying interface re-joined. | 355 | 372 | """ | 356 | 373 | has_v6 = any(isinstance(interface, tuple) for interface in desired.values()) | 357 | 374 | has_v4 = any(not isinstance(interface, tuple) for interface in desired.values()) | @@ -364,12 +381,14 @@ async def _async_rebuild_listen_socket( | 364 | 381 | new_listen = new_listen_socket(family_version, apple_p2p) | 365 | 382 | if new_listen is None: | 366 | 383 | raise RuntimeError("Failed to create a listen socket for the new interface family") | | 384 | + rejoined = True | 367 | 385 | try: | 368 | 386 | for bind_address, interface in desired.items(): | 369 | 387 | # A staying interface that can't re-join on the new socket keeps | 370 | 388 | # its sender but receives only via the shared socket it never | 371 | 389 | # joined; surface that degraded state like _async_add_interface. | 372 | 390 | if bind_address in current and not add_multicast_member(new_listen, interface): | | 391 | + rejoined = False | 373 | 392 | self.zc.log_warning_once( | 374 | 393 | f"Interface {interface!r} could not re-join the multicast group " | 375 | 394 | "on the rebuilt listen socket" | @@ -388,6 +407,7 @@ async def _async_rebuild_listen_socket( | 388 | 407 | old_transport = old_listen_transport.transport | 389 | 408 | self._async_remove_transport(old_transport) | 390 | 409 | old_transport.close() | | 410 | + return rejoined | 391 | 411 |
| 392 | 412 | def _async_remove_transport(self, transport: asyncio.DatagramTransport) -> None: | 393 | 413 | """Drop a transport's protocol/reader/sender wrappers, cancelling its timers.""" | |
Collapse file Copy file name to clipboardExpand all lines: src/zeroconf/asyncio.py +4-3Lines changed: 4 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -230,16 +230,17 @@ async def async_update_interfaces( | 230 | 230 | interfaces: InterfacesType | None = None, | 231 | 231 | ip_version: IPVersion | None = None, | 232 | 232 | apple_p2p: bool | None = None, | 233 | | - ) -> None: | | 233 | + ) -> bool: | 234 | 234 | """Rescan network interfaces and reconcile the sockets in use. | 235 | 235 |
| 236 | 236 | Adds sockets for interfaces that appeared, drops sockets for | 237 | 237 | interfaces that disappeared, and re-announces existing | 238 | 238 | registrations on the resulting senders. ``interfaces``, | 239 | 239 | ``ip_version`` and ``apple_p2p`` each default to the construction-time | 240 | | - value. Raises RuntimeError if apple_p2p is set on a non-Apple platform. | | 240 | + value. Returns whether every requested interface is up. Raises | | 241 | + RuntimeError if apple_p2p is set on a non-Apple platform. | 241 | 242 | """ | 242 | | - await self.zeroconf.async_update_interfaces(interfaces, ip_version, apple_p2p) | | 243 | + return await self.zeroconf.async_update_interfaces(interfaces, ip_version, apple_p2p) | 243 | 244 |
| 244 | 245 | async def async_update_service(self, info: ServiceInfo) -> Awaitable: | 245 | 246 | """Publish updated records for an already registered service. | |
|