Skip to content

Commit 4699b53

Browse files
committed
feat: add opt-in periodic interface-change monitor
1 parent f7b6624 commit 4699b53

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,
@@ -221,6 +222,7 @@ def __init__(
221222
self.record_manager = RecordManager(self)
222223

223224
self._notify_futures: set[asyncio.Future] = set()
225+
self._interface_monitor: InterfaceMonitor | None = None
224226
self.loop: asyncio.AbstractEventLoop | None = None
225227
self._loop_thread: threading.Thread | None = None
226228

@@ -444,6 +446,28 @@ async def async_update_interfaces(self, interfaces: InterfacesType | None = None
444446
]
445447
)
446448

449+
async def async_start_interface_monitor(
450+
self, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL
451+
) -> None:
452+
"""Start an opt-in poller that rescans interfaces when adapters change.
453+
454+
Interface change detection is platform specific; by default zeroconf
455+
leaves it to the consumer. This polls every ``interval`` seconds and
456+
calls `async_update_interfaces` when the address set changes. It is a
457+
no-op if the monitor is already running.
458+
"""
459+
await self.async_wait_for_start()
460+
if self._interface_monitor is None:
461+
self._interface_monitor = InterfaceMonitor(self, interval)
462+
self._interface_monitor.start()
463+
464+
async def async_stop_interface_monitor(self) -> None:
465+
"""Stop the interface monitor if it is running."""
466+
monitor = self._interface_monitor
467+
if monitor is not None:
468+
self._interface_monitor = None
469+
await monitor.async_stop()
470+
447471
async def async_get_service_info(
448472
self,
449473
type_: str,
@@ -781,6 +805,7 @@ async def _async_close(self) -> None:
781805
before calling this function
782806
"""
783807
self._close()
808+
await self.async_stop_interface_monitor()
784809
await self.engine._async_close() # pylint: disable=protected-access
785810
self._shutdown_threads()
786811

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

@@ -233,6 +234,21 @@ async def async_update_interfaces(self, interfaces: InterfacesType | None = None
233234
"""
234235
await self.zeroconf.async_update_interfaces(interfaces)
235236

237+
async def async_start_interface_monitor(
238+
self, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL
239+
) -> None:
240+
"""Start an opt-in poller that rescans interfaces when adapters change.
241+
242+
Interface change detection is platform specific; by default zeroconf
243+
leaves it to the consumer. This polls every ``interval`` seconds and
244+
reconciles the sockets in use when the address set changes.
245+
"""
246+
await self.zeroconf.async_start_interface_monitor(interval)
247+
248+
async def async_stop_interface_monitor(self) -> None:
249+
"""Stop the interface monitor if it is running."""
250+
await self.zeroconf.async_stop_interface_monitor()
251+
236252
async def async_close(self) -> None:
237253
"""Ends the background threads, and prevent this instance from
238254
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)