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