Skip to content

Commit 471feb4

Browse files
authored
feat: add async_update_interfaces to rescan network interfaces at runtime (#1797)
1 parent 797fdc0 commit 471feb4

9 files changed

Lines changed: 1627 additions & 31 deletions

File tree

src/zeroconf/_core.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,12 @@ def __init__(
199199

200200
self.unicast = unicast
201201
self._use_asyncio = use_asyncio
202+
# Retained so async_update_interfaces can re-run create_sockets /
203+
# normalize_interface_choice against the live interface set later.
204+
# Copy a mutable list so later caller mutation can't change it.
205+
self._interfaces = list(interfaces) if isinstance(interfaces, list) else interfaces
206+
self._ip_version = ip_version
207+
self._apple_p2p = apple_p2p
202208
listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p)
203209
log.debug("Listen socket %s, respond sockets %s", listen_socket, respond_sockets)
204210

@@ -216,6 +222,9 @@ def __init__(
216222
self.record_manager = RecordManager(self)
217223

218224
self._notify_futures: set[asyncio.Future] = set()
225+
# Serializes async_update_interfaces so overlapping calls (a bursty
226+
# adapter-change source) don't diff against a stale sender snapshot.
227+
self._interface_update_lock = asyncio.Lock()
219228
self.loop: asyncio.AbstractEventLoop | None = None
220229
self._loop_thread: threading.Thread | None = None
221230

@@ -406,6 +415,91 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable:
406415
self.registry.async_update(info)
407416
return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None))
408417

418+
def update_interfaces(
419+
self,
420+
interfaces: InterfacesType | None = None,
421+
ip_version: IPVersion | None = None,
422+
apple_p2p: bool | None = None,
423+
) -> None:
424+
"""Rescan network interfaces and reconcile the sockets in use.
425+
426+
While it is not expected during normal operation,
427+
this function may raise EventLoopBlocked if the underlying
428+
call to `async_update_interfaces` cannot be completed. Raises
429+
RuntimeError if apple_p2p is set on a non-Apple platform.
430+
"""
431+
assert self.loop is not None
432+
# Unlike register/update, the re-announce is awaited inline (to log
433+
# per-service failures), so the budget must cover the full announce
434+
# window ((_REGISTER_BROADCASTS - 1) * _REGISTER_TIME) plus the reconcile
435+
# and wait-for-start overhead; double the register budget for headroom.
436+
run_coro_with_timeout(
437+
self.async_update_interfaces(interfaces, ip_version, apple_p2p),
438+
self.loop,
439+
_REGISTER_TIME * _REGISTER_BROADCASTS * 2,
440+
)
441+
442+
async def async_update_interfaces(
443+
self,
444+
interfaces: InterfacesType | None = None,
445+
ip_version: IPVersion | None = None,
446+
apple_p2p: bool | None = None,
447+
) -> None:
448+
"""Rescan network interfaces and reconcile the sockets in use.
449+
450+
Adds sockets for interfaces that appeared, drops sockets for
451+
interfaces that disappeared, and re-announces existing
452+
registrations when a new sender appeared. ``interfaces``,
453+
``ip_version`` and ``apple_p2p`` each default to the value passed at
454+
construction; pass a new value to switch it. When the resulting
455+
interface set is unchanged this is a no-op (no sockets touched,
456+
nothing re-announced). The listen socket is rebuilt if the new set
457+
needs a different address family; unicast mode is fixed at
458+
construction. Concurrent calls are serialized. Bringing up interfaces
459+
is best-effort: a requested interface that fails to bind, or fails to
460+
re-join after a rebuild, is logged rather than raised, and likewise a
461+
registration that fails to re-announce is logged so one failure cannot
462+
block the others. Raises RuntimeError if apple_p2p is set on a non-Apple
463+
platform (input validation, matching the constructor).
464+
"""
465+
# Resolve against the retained config but only commit it after the
466+
# engine reconcile succeeds, so a failed reconcile leaves the stored
467+
# config unchanged rather than recording a set that never fully bound
468+
# (a mid-reconcile failure may still have changed some sockets).
469+
interfaces = self._interfaces if interfaces is None else interfaces
470+
ip_version = self._ip_version if ip_version is None else ip_version
471+
apple_p2p = self._apple_p2p if apple_p2p is None else apple_p2p
472+
if apple_p2p and sys.platform != "darwin":
473+
raise RuntimeError("Option `apple_p2p` is not supported on non-Apple platforms.")
474+
await self.async_wait_for_start()
475+
# Only the reconcile mutates the sender set, so hold the lock for that
476+
# alone; the multi-second re-announce runs unlocked so a bursty
477+
# adapter-change source isn't blocked behind it.
478+
async with self._interface_update_lock:
479+
added = await self.engine.async_update_interfaces(interfaces, ip_version, apple_p2p)
480+
# Copy a mutable list so later caller mutation can't change the
481+
# retained configuration.
482+
self._interfaces = list(interfaces) if isinstance(interfaces, list) else interfaces
483+
self._ip_version = ip_version
484+
self._apple_p2p = apple_p2p
485+
if not added:
486+
return
487+
# Re-announce every registration; one broadcast failing must not mask
488+
# the rest, so collect exceptions and log them individually, naming the
489+
# service so a partial failure is actionable.
490+
infos = self.registry.async_get_service_infos()
491+
results = await asyncio.gather(
492+
*[self._async_broadcast_service(info, _REGISTER_TIME, None) for info in infos],
493+
return_exceptions=True,
494+
)
495+
for info, result in zip(infos, results, strict=True):
496+
if isinstance(result, Exception):
497+
log.warning("Error re-announcing %s after interface update: %s", info.name, result)
498+
elif isinstance(result, BaseException):
499+
# gather(return_exceptions=True) also captures BaseExceptions
500+
# such as CancelledError; don't swallow a cancellation/interrupt.
501+
raise result
502+
409503
async def async_get_service_info(
410504
self,
411505
type_: str,

0 commit comments

Comments
 (0)