Skip to content

Commit cc9f207

Browse files
committed
fix: set changed size during iteration when dispatching listeners
An existing listener may add new listeners to process ServiceInfo when it sees a record. We need to make a copy of the listeners set before iterating them to avoid `set changed size during iteration` Fixes ``` 2024-04-12 16:31:25.699 ERROR (MainThread) [homeassistant] Error doing job: Exception in callback _SelectorDatagramTransport._read_ready() Traceback (most recent call last): File "/opt/homebrew/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/asyncio/events.py", line 88, in _run self._context.run(self._callback, *self._args) File "/opt/homebrew/Cellar/python@3.12/3.12.2_1/Frameworks/Python.framework/Versions/3.12/lib/python3.12/asyncio/selector_events.py", line 1248, in _read_ready self._protocol.datagram_received(data, addr) File "src/zeroconf/_listener.py", line 86, in zeroconf._listener.AsyncListener.datagram_received File "src/zeroconf/_listener.py", line 104, in zeroconf._listener.AsyncListener.datagram_received File "src/zeroconf/_listener.py", line 175, in zeroconf._listener.AsyncListener._process_datagram_at_time File "src/zeroconf/_handlers/record_manager.py", line 161, in zeroconf._handlers.record_manager.RecordManager.async_updates_from_response File "src/zeroconf/_handlers/record_manager.py", line 70, in zeroconf._handlers.record_manager.RecordManager.async_updates_complete RuntimeError: set changed size during iteration ```
1 parent 0758c1e commit cc9f207

2 files changed

Lines changed: 76 additions & 2 deletions

File tree

src/zeroconf/_handlers/record_manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def async_updates(self, now: _float, records: List[RecordUpdate]) -> None:
5656
5757
This method will be run in the event loop.
5858
"""
59-
for listener in self.listeners:
59+
for listener in self.listeners.copy():
6060
listener.async_update_records(self.zc, now, records)
6161

6262
def async_updates_complete(self, notify: bool) -> None:
@@ -67,7 +67,7 @@ def async_updates_complete(self, notify: bool) -> None:
6767
6868
This method will be run in the event loop.
6969
"""
70-
for listener in self.listeners:
70+
for listener in self.listeners.copy():
7171
listener.async_update_records_complete()
7272
if notify:
7373
self.zc.async_notify_all()

tests/test_handlers.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,3 +1762,77 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.Recor
17621762
)
17631763

17641764
await aiozc.async_close()
1765+
1766+
1767+
@pytest.mark.asyncio
1768+
async def test_async_updates_iteration_safe():
1769+
"""Ensure we can safely iterate over the async_updates."""
1770+
1771+
aiozc = AsyncZeroconf(interfaces=['127.0.0.1'])
1772+
zc: Zeroconf = aiozc.zeroconf
1773+
updated = []
1774+
good_bye_answer = r.DNSPointer(
1775+
"myservicelow_tcp._tcp.local.",
1776+
const._TYPE_PTR,
1777+
const._CLASS_IN | const._CLASS_UNIQUE,
1778+
0,
1779+
'goodbye.local.',
1780+
)
1781+
1782+
class OtherListener(r.RecordUpdateListener):
1783+
"""A RecordUpdateListener that does not implement update_records."""
1784+
1785+
def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None:
1786+
"""Update multiple records in one shot."""
1787+
updated.extend(records)
1788+
1789+
other = OtherListener()
1790+
1791+
class ListenerThatAddsListener(r.RecordUpdateListener):
1792+
"""A RecordUpdateListener that does not implement update_records."""
1793+
1794+
def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.RecordUpdate]) -> None:
1795+
"""Update multiple records in one shot."""
1796+
updated.extend(records)
1797+
zc.async_add_listener(other, None)
1798+
1799+
zc.async_add_listener(ListenerThatAddsListener(), None)
1800+
await asyncio.sleep(0) # flush out any call soons
1801+
1802+
# This should not raise RuntimeError: set changed size during iteration
1803+
zc.record_manager.async_updates(
1804+
now=current_time_millis(), records=[r.RecordUpdate(good_bye_answer, None)]
1805+
)
1806+
1807+
assert len(updated) == 1
1808+
await aiozc.async_close()
1809+
1810+
1811+
@pytest.mark.asyncio
1812+
async def test_async_updates_complete_iteration_safe():
1813+
"""Ensure we can safely iterate over the async_updates_complete."""
1814+
1815+
aiozc = AsyncZeroconf(interfaces=['127.0.0.1'])
1816+
zc: Zeroconf = aiozc.zeroconf
1817+
1818+
class OtherListener(r.RecordUpdateListener):
1819+
"""A RecordUpdateListener that does not implement update_records."""
1820+
1821+
def async_update_records_complete(self) -> None:
1822+
"""Update multiple records in one shot."""
1823+
1824+
other = OtherListener()
1825+
1826+
class ListenerThatAddsListener(r.RecordUpdateListener):
1827+
"""A RecordUpdateListener that does not implement update_records."""
1828+
1829+
def async_update_records_complete(self) -> None:
1830+
"""Update multiple records in one shot."""
1831+
zc.async_add_listener(other, None)
1832+
1833+
zc.async_add_listener(ListenerThatAddsListener(), None)
1834+
await asyncio.sleep(0) # flush out any call soons
1835+
1836+
# This should not raise RuntimeError: set changed size during iteration
1837+
zc.record_manager.async_updates_complete(False)
1838+
await aiozc.async_close()

0 commit comments

Comments
 (0)