Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
48c5fe5
feat: add async_update_interfaces to rescan network interfaces at run…
bdraco Jun 21, 2026
6199f8a
Merge branch 'master' into feat/async-update-interfaces
bdraco Jun 22, 2026
e3afb88
address review: guard Default kind-switch, unlock re-announce, copy i…
bdraco Jun 22, 2026
60eddff
harden: narrow getsockopt mask logging, roll back per-interface add o…
bdraco Jun 22, 2026
6e5d314
test: drive Default/unicast cases at the engine level to avoid real-m…
bdraco Jun 22, 2026
e05addd
harden: cancel removed interface listener TC timers; warn on unexpect…
bdraco Jun 22, 2026
3dae38b
feat: rebuild listen socket on incompatible family change instead of …
bdraco Jun 22, 2026
d05d3e2
fix: rebuild listen socket safely - derive family from desired set, c…
bdraco Jun 22, 2026
86771c6
refactor: drop redundant rebuild return value; trim duplicate rebuild…
bdraco Jun 22, 2026
2058592
refactor: share interface-setup helpers between construction and rescan
bdraco Jun 22, 2026
2d4b61b
feat: reconcile a Default single-family instance to an explicit set
bdraco Jun 22, 2026
2b37423
docs: correct the no-await invariant comment in _async_create_endpoints
bdraco Jun 22, 2026
59d9c80
refactor: dedup transport-list filtering; pop the demoted key instead…
bdraco Jun 22, 2026
12bba00
fix: warn when a staying interface fails to re-join on the rebuilt li…
bdraco Jun 22, 2026
4657975
test: pin re-announce fan-out, ip-change, and v6 leave-index behavior
bdraco Jun 22, 2026
4a2f0e3
fix: only swallow IPV6_V6ONLY read failure on Windows
bdraco Jun 22, 2026
4086ae2
fix: name the service in re-announce failures; widen sync update timeout
bdraco Jun 22, 2026
37f6b1a
fix: degrade getsockopt read failures instead of aborting
bdraco Jun 22, 2026
c93f537
fix: surface an unreadable IPV6_V6ONLY at warning, document best-effo…
bdraco Jun 22, 2026
423f875
fix: make rescan resilient to a transient empty set and per-interface…
bdraco Jun 22, 2026
55ca5ea
fix: only swallow OSError when a per-interface endpoint fails
bdraco Jun 22, 2026
f0090e4
fix: don't degrade or crash on a listen-socket rebuild failure
bdraco Jun 22, 2026
c2df902
fix: don't drop a cancelled re-announce; document the apple_p2p raise
bdraco Jun 22, 2026
ca0e061
feat: add opt-in periodic interface-change monitor
bdraco Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/zeroconf/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
wait_for_future_set_or_timeout,
wait_future_or_timeout,
)
from ._utils.interface_monitor import _DEFAULT_INTERFACE_MONITOR_INTERVAL, InterfaceMonitor
from ._utils.name import service_type_name
from ._utils.net import (
InterfaceChoice,
Expand Down Expand Up @@ -199,6 +200,12 @@ def __init__(

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

Expand All @@ -216,6 +223,10 @@ def __init__(
self.record_manager = RecordManager(self)

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

Expand Down Expand Up @@ -406,6 +417,113 @@ async def async_update_service(self, info: ServiceInfo) -> Awaitable:
self.registry.async_update(info)
return asyncio.ensure_future(self._async_broadcast_service(info, _REGISTER_TIME, None))

def update_interfaces(
self,
interfaces: InterfacesType | None = None,
ip_version: IPVersion | None = None,
apple_p2p: bool | None = None,
) -> None:
"""Rescan network interfaces and reconcile the sockets in use.

While it is not expected during normal operation,
this function may raise EventLoopBlocked if the underlying
call to `async_update_interfaces` cannot be completed. Raises
RuntimeError if apple_p2p is set on a non-Apple platform.
"""
assert self.loop is not None
# Unlike register/update, the re-announce is awaited inline (to log
# per-service failures), so the budget must cover the full announce
# window ((_REGISTER_BROADCASTS - 1) * _REGISTER_TIME) plus the reconcile
# and wait-for-start overhead; double the register budget for headroom.
run_coro_with_timeout(
self.async_update_interfaces(interfaces, ip_version, apple_p2p),
self.loop,
_REGISTER_TIME * _REGISTER_BROADCASTS * 2,
)

async def async_update_interfaces(
self,
interfaces: InterfacesType | None = None,
ip_version: IPVersion | None = None,
apple_p2p: bool | None = None,
) -> None:
"""Rescan network interfaces and reconcile the sockets in use.

Adds sockets for interfaces that appeared, drops sockets for
interfaces that disappeared, and re-announces existing
registrations when a new sender appeared. ``interfaces``,
``ip_version`` and ``apple_p2p`` each default to the value passed at
construction; pass a new value to switch it. When the resulting
interface set is unchanged this is a no-op (no sockets touched,
nothing re-announced). The listen socket is rebuilt if the new set
needs a different address family; unicast mode is fixed at
construction. Concurrent calls are serialized. Bringing up interfaces
is best-effort: a requested interface that fails to bind, or fails to
re-join after a rebuild, is logged rather than raised, and likewise a
registration that fails to re-announce is logged so one failure cannot
block the others. Raises RuntimeError if apple_p2p is set on a non-Apple
platform (input validation, matching the constructor).
"""
# Resolve against the retained config but only commit it after the
# engine reconcile succeeds, so a failed reconcile leaves the stored
# config unchanged rather than recording a set that never fully bound
# (a mid-reconcile failure may still have changed some sockets).
interfaces = self._interfaces if interfaces is None else interfaces
ip_version = self._ip_version if ip_version is None else ip_version
apple_p2p = self._apple_p2p if apple_p2p is None else apple_p2p
if apple_p2p and sys.platform != "darwin":
raise RuntimeError("Option `apple_p2p` is not supported on non-Apple platforms.")
await self.async_wait_for_start()
# Only the reconcile mutates the sender set, so hold the lock for that
# alone; the multi-second re-announce runs unlocked so a bursty
# adapter-change source isn't blocked behind it.
async with self._interface_update_lock:
added = await self.engine.async_update_interfaces(interfaces, ip_version, apple_p2p)
# Copy a mutable list so later caller mutation can't change the
# retained configuration.
self._interfaces = list(interfaces) if isinstance(interfaces, list) else interfaces
self._ip_version = ip_version
self._apple_p2p = apple_p2p
if not added:
return
# Re-announce every registration; one broadcast failing must not mask
# the rest, so collect exceptions and log them individually, naming the
# service so a partial failure is actionable.
infos = self.registry.async_get_service_infos()
results = await asyncio.gather(
*[self._async_broadcast_service(info, _REGISTER_TIME, None) for info in infos],
return_exceptions=True,
)
for info, result in zip(infos, results, strict=True):
if isinstance(result, Exception):
log.warning("Error re-announcing %s after interface update: %s", info.name, result)
elif isinstance(result, BaseException):
# gather(return_exceptions=True) also captures BaseExceptions
# such as CancelledError; don't swallow a cancellation/interrupt.
raise result

async def async_start_interface_monitor(
self, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL
) -> None:
"""Start an opt-in poller that rescans interfaces when adapters change.

Interface change detection is platform specific; by default zeroconf
leaves it to the consumer. This polls every ``interval`` seconds and
calls `async_update_interfaces` when the address set changes. It is a
no-op if the monitor is already running.
"""
await self.async_wait_for_start()
if self._interface_monitor is None:
self._interface_monitor = InterfaceMonitor(self, interval)
self._interface_monitor.start()

async def async_stop_interface_monitor(self) -> None:
"""Stop the interface monitor if it is running."""
monitor = self._interface_monitor
if monitor is not None:
self._interface_monitor = None
await monitor.async_stop()

async def async_get_service_info(
self,
type_: str,
Expand Down Expand Up @@ -743,6 +861,7 @@ async def _async_close(self) -> None:
before calling this function
"""
self._close()
await self.async_stop_interface_monitor()
await self.engine._async_close() # pylint: disable=protected-access
self._shutdown_threads()

Expand Down
Loading
Loading