Skip to content

Commit ca0e061

Browse files
committed
feat: add opt-in periodic interface-change monitor
1 parent c2df902 commit ca0e061

4 files changed

Lines changed: 266 additions & 0 deletions

File tree

src/zeroconf/_core.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
wait_for_future_set_or_timeout,
6060
wait_future_or_timeout,
6161
)
62+
from ._utils.interface_monitor import _DEFAULT_INTERFACE_MONITOR_INTERVAL, InterfaceMonitor
6263
from ._utils.name import service_type_name
6364
from ._utils.net import (
6465
InterfaceChoice,
@@ -225,6 +226,7 @@ def __init__(
225226
# Serializes async_update_interfaces so overlapping calls (a bursty
226227
# adapter-change source) don't diff against a stale sender snapshot.
227228
self._interface_update_lock = asyncio.Lock()
229+
self._interface_monitor: InterfaceMonitor | None = None
228230
self.loop: asyncio.AbstractEventLoop | None = None
229231
self._loop_thread: threading.Thread | None = None
230232

@@ -500,6 +502,28 @@ async def async_update_interfaces(
500502
# such as CancelledError; don't swallow a cancellation/interrupt.
501503
raise result
502504

505+
async def async_start_interface_monitor(
506+
self, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL
507+
) -> None:
508+
"""Start an opt-in poller that rescans interfaces when adapters change.
509+
510+
Interface change detection is platform specific; by default zeroconf
511+
leaves it to the consumer. This polls every ``interval`` seconds and
512+
calls `async_update_interfaces` when the address set changes. It is a
513+
no-op if the monitor is already running.
514+
"""
515+
await self.async_wait_for_start()
516+
if self._interface_monitor is None:
517+
self._interface_monitor = InterfaceMonitor(self, interval)
518+
self._interface_monitor.start()
519+
520+
async def async_stop_interface_monitor(self) -> None:
521+
"""Stop the interface monitor if it is running."""
522+
monitor = self._interface_monitor
523+
if monitor is not None:
524+
self._interface_monitor = None
525+
await monitor.async_stop()
526+
503527
async def async_get_service_info(
504528
self,
505529
type_: str,
@@ -837,6 +861,7 @@ async def _async_close(self) -> None:
837861
before calling this function
838862
"""
839863
self._close()
864+
await self.async_stop_interface_monitor()
840865
await self.engine._async_close() # pylint: disable=protected-access
841866
self._shutdown_threads()
842867

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Optional periodic interface-change monitor.
2+
3+
Interface change detection is platform specific and is left to the consumer
4+
by default. This convenience monitor polls ``ifaddr.get_adapters`` and calls
5+
``Zeroconf.async_update_interfaces`` when the set of interface addresses
6+
changes, so a consumer that has no native change signal can still reconcile
7+
sockets without restarting the instance.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import asyncio
13+
import contextlib
14+
from typing import TYPE_CHECKING
15+
16+
import ifaddr
17+
18+
from .._logger import log
19+
20+
if TYPE_CHECKING:
21+
from .._core import Zeroconf
22+
23+
_DEFAULT_INTERFACE_MONITOR_INTERVAL = 5.0 # seconds
24+
25+
26+
def _adapter_snapshot() -> frozenset[tuple[int | None, str]]:
27+
"""Return a hashable snapshot of every adapter index and address."""
28+
return frozenset((adapter.index, str(ip.ip)) for adapter in ifaddr.get_adapters() for ip in adapter.ips)
29+
30+
31+
class InterfaceMonitor:
32+
"""Poll for adapter changes and rescan interfaces when they change."""
33+
34+
__slots__ = ("_interval", "_snapshot", "_task", "_zc")
35+
36+
def __init__(self, zc: Zeroconf, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL) -> None:
37+
self._zc = zc
38+
self._interval = interval
39+
self._snapshot = _adapter_snapshot()
40+
self._task: asyncio.Task[None] | None = None
41+
42+
def start(self) -> None:
43+
"""Start the poll task on the running loop."""
44+
assert self._zc.loop is not None
45+
if self._task is None:
46+
self._task = self._zc.loop.create_task(self._async_run())
47+
48+
async def async_stop(self) -> None:
49+
"""Cancel the poll task and wait for it to finish."""
50+
task = self._task
51+
if task is None:
52+
return
53+
self._task = None
54+
task.cancel()
55+
with contextlib.suppress(asyncio.CancelledError):
56+
await task
57+
58+
async def _async_run(self) -> None:
59+
"""Rescan interfaces whenever the adapter snapshot changes."""
60+
while True:
61+
await asyncio.sleep(self._interval)
62+
snapshot = _adapter_snapshot()
63+
if snapshot == self._snapshot:
64+
continue
65+
self._snapshot = snapshot
66+
try:
67+
await self._zc.async_update_interfaces()
68+
except Exception:
69+
# A transient failure must not kill the monitor; the next
70+
# change still triggers a rescan.
71+
log.exception("Interface rescan failed")

src/zeroconf/asyncio.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from ._services.browser import _ServiceBrowserBase
3535
from ._services.info import AsyncServiceInfo, ServiceInfo
3636
from ._services.types import ZeroconfServiceTypes
37+
from ._utils.interface_monitor import _DEFAULT_INTERFACE_MONITOR_INTERVAL
3738
from ._utils.net import InterfaceChoice, InterfacesType, IPVersion
3839
from .const import _BROWSER_TIME, _MDNS_PORT, _SERVICE_TYPE_ENUMERATION_NAME
3940

@@ -240,6 +241,21 @@ async def async_update_interfaces(
240241
"""
241242
await self.zeroconf.async_update_interfaces(interfaces, ip_version, apple_p2p)
242243

244+
async def async_start_interface_monitor(
245+
self, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL
246+
) -> None:
247+
"""Start an opt-in poller that rescans interfaces when adapters change.
248+
249+
Interface change detection is platform specific; by default zeroconf
250+
leaves it to the consumer. This polls every ``interval`` seconds and
251+
reconciles the sockets in use when the address set changes.
252+
"""
253+
await self.zeroconf.async_start_interface_monitor(interval)
254+
255+
async def async_stop_interface_monitor(self) -> None:
256+
"""Stop the interface monitor if it is running."""
257+
await self.zeroconf.async_stop_interface_monitor()
258+
243259
async def async_close(self) -> None:
244260
"""Ends the background threads, and prevent this instance from
245261
servicing further queries."""

tests/test_interface_monitor.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Unit tests for the opt-in periodic interface-change monitor."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from collections.abc import Callable
7+
from unittest.mock import Mock, patch
8+
9+
import pytest
10+
11+
from zeroconf._utils import interface_monitor as im
12+
from zeroconf._utils.interface_monitor import InterfaceMonitor
13+
from zeroconf.asyncio import AsyncZeroconf
14+
15+
16+
def _snapshot_cycler(snapshots: list[frozenset]) -> Callable[[], frozenset]:
17+
"""Return the next snapshot each call, sticking on the last one."""
18+
it = iter(snapshots)
19+
20+
def _next() -> frozenset:
21+
try:
22+
return next(it)
23+
except StopIteration:
24+
return snapshots[-1]
25+
26+
return _next
27+
28+
29+
def test_adapter_snapshot() -> None:
30+
adapter = Mock()
31+
adapter.index = 1
32+
ip = Mock()
33+
ip.ip = "192.168.1.5"
34+
adapter.ips = [ip]
35+
with patch.object(im.ifaddr, "get_adapters", return_value=[adapter]):
36+
assert im._adapter_snapshot() == frozenset({(1, "192.168.1.5")})
37+
38+
39+
@pytest.mark.asyncio
40+
async def test_monitor_rescans_on_change(aiozc_loopback: AsyncZeroconf) -> None:
41+
"""A changed adapter snapshot triggers a rescan."""
42+
zc = aiozc_loopback.zeroconf
43+
await zc.async_wait_for_start()
44+
updated = asyncio.Event()
45+
46+
async def _fake_update() -> None:
47+
updated.set()
48+
49+
with (
50+
patch.object(
51+
im, "_adapter_snapshot", side_effect=_snapshot_cycler([frozenset({"a"}), frozenset({"b"})])
52+
),
53+
patch.object(zc, "async_update_interfaces", side_effect=_fake_update),
54+
):
55+
await aiozc_loopback.async_start_interface_monitor(interval=0.001)
56+
await asyncio.wait_for(updated.wait(), timeout=1.0)
57+
await aiozc_loopback.async_stop_interface_monitor()
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_monitor_no_rescan_when_unchanged(aiozc_loopback: AsyncZeroconf) -> None:
62+
"""An unchanged snapshot does not trigger a rescan."""
63+
zc = aiozc_loopback.zeroconf
64+
await zc.async_wait_for_start()
65+
with (
66+
patch.object(im, "_adapter_snapshot", return_value=frozenset({"same"})),
67+
patch.object(zc, "async_update_interfaces") as mock_update,
68+
):
69+
await aiozc_loopback.async_start_interface_monitor(interval=0.001)
70+
await asyncio.sleep(0.02)
71+
await aiozc_loopback.async_stop_interface_monitor()
72+
mock_update.assert_not_called()
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_monitor_survives_rescan_error(aiozc_loopback: AsyncZeroconf) -> None:
77+
"""A failed rescan is logged and the monitor keeps running."""
78+
zc = aiozc_loopback.zeroconf
79+
await zc.async_wait_for_start()
80+
calls = []
81+
82+
async def _boom() -> None:
83+
calls.append(1)
84+
raise RuntimeError("boom")
85+
86+
snapshots = [frozenset({"a"}), frozenset({"b"}), frozenset({"c"})]
87+
with (
88+
patch.object(im, "_adapter_snapshot", side_effect=_snapshot_cycler(snapshots)),
89+
patch.object(zc, "async_update_interfaces", side_effect=_boom),
90+
):
91+
await aiozc_loopback.async_start_interface_monitor(interval=0.001)
92+
await asyncio.sleep(0.05)
93+
await aiozc_loopback.async_stop_interface_monitor()
94+
assert len(calls) >= 2
95+
96+
97+
@pytest.mark.asyncio
98+
async def test_start_interface_monitor_idempotent(aiozc_loopback: AsyncZeroconf) -> None:
99+
"""Starting an already-running monitor is a no-op."""
100+
zc = aiozc_loopback.zeroconf
101+
await zc.async_wait_for_start()
102+
with patch.object(im, "_adapter_snapshot", return_value=frozenset()):
103+
await aiozc_loopback.async_start_interface_monitor(interval=10)
104+
monitor = zc._interface_monitor
105+
assert monitor is not None
106+
task = monitor._task
107+
await aiozc_loopback.async_start_interface_monitor(interval=10)
108+
assert zc._interface_monitor is monitor
109+
assert monitor._task is task
110+
await aiozc_loopback.async_stop_interface_monitor()
111+
112+
113+
@pytest.mark.asyncio
114+
async def test_monitor_start_idempotent(aiozc_loopback: AsyncZeroconf) -> None:
115+
"""InterfaceMonitor.start is a no-op when a task is already scheduled."""
116+
zc = aiozc_loopback.zeroconf
117+
await zc.async_wait_for_start()
118+
with patch.object(im, "_adapter_snapshot", return_value=frozenset()):
119+
monitor = InterfaceMonitor(zc, interval=10)
120+
monitor.start()
121+
task = monitor._task
122+
monitor.start()
123+
assert monitor._task is task
124+
await monitor.async_stop()
125+
126+
127+
@pytest.mark.asyncio
128+
async def test_monitor_stop_without_start(aiozc_loopback: AsyncZeroconf) -> None:
129+
"""Stopping a monitor that never started is a no-op."""
130+
zc = aiozc_loopback.zeroconf
131+
await zc.async_wait_for_start()
132+
with patch.object(im, "_adapter_snapshot", return_value=frozenset()):
133+
monitor = InterfaceMonitor(zc)
134+
await monitor.async_stop()
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_core_stop_interface_monitor_when_none(aiozc_loopback: AsyncZeroconf) -> None:
139+
"""Stopping the monitor when none is running is a no-op."""
140+
await aiozc_loopback.zeroconf.async_wait_for_start()
141+
await aiozc_loopback.async_stop_interface_monitor()
142+
143+
144+
@pytest.mark.asyncio
145+
async def test_monitor_stopped_on_close() -> None:
146+
"""async_close stops a running interface monitor."""
147+
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
148+
zc = aiozc.zeroconf
149+
await zc.async_wait_for_start()
150+
with patch.object(im, "_adapter_snapshot", return_value=frozenset()):
151+
await aiozc.async_start_interface_monitor(interval=10)
152+
assert zc._interface_monitor is not None
153+
await aiozc.async_close()
154+
assert zc._interface_monitor is None

0 commit comments

Comments
 (0)