Skip to content

Commit 94fc4ec

Browse files
committed
fix(core): release sockets when close runs before engine setup completes
When ``Zeroconf`` is constructed inside a running asyncio loop, the engine's socket→transport wrapping happens in a scheduled task. If the caller closed the instance before that task ran (e.g. an error path that bails out immediately, or a sync ``zc.close()`` called from the same loop before any await), ``_async_shutdown`` had no transports to close and the raw sockets passed to the engine in ``__init__`` leaked their FDs until interpreter shutdown. ``_async_create_endpoints`` now releases its handle on each socket as the transport adopts it, and ``_async_shutdown`` cancels any pending setup task and closes whatever sockets were never adopted. The async close path tolerates a cancelled setup task instead of asserting on ``_cleanup_timer`` that was never scheduled. Also clean up the test-side leaks surfaced by issue #1133: add the missing ``async_close`` / ``close`` calls in tests that constructed a ``Zeroconf`` or ``AsyncZeroconf`` and never tore it down, give ``test_shutdown_loop`` an explicit ``loop.close()``, and add a no-op ``update_service`` to the ``ServiceListener`` subclass in ``test_service_browser_listeners_no_update_service`` so the browser thread doesn't surface a ``NotImplementedError``. Closes #1133.
1 parent d03ea36 commit 94fc4ec

7 files changed

Lines changed: 45 additions & 6 deletions

File tree

src/zeroconf/_engine.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from __future__ import annotations
2424

2525
import asyncio
26+
import contextlib
2627
import itertools
2728
import socket
2829
import threading
@@ -114,6 +115,13 @@ async def _async_create_endpoints(self) -> None:
114115
lambda: AsyncListener(self.zc), # type: ignore[arg-type, return-value]
115116
sock=s,
116117
)
118+
# The transport now owns ``s``; drop our reference so
119+
# ``_async_shutdown`` only closes sockets that never reached a
120+
# transport (e.g. when setup is cancelled mid-flight).
121+
if s is self._listen_socket:
122+
self._listen_socket = None
123+
if s in self._respond_sockets:
124+
self._respond_sockets.remove(s)
117125
self.protocols.append(cast(AsyncListener, protocol))
118126
self.readers.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport)))
119127
if s in sender_sockets:
@@ -139,19 +147,36 @@ def _async_schedule_next_cache_cleanup(self) -> None:
139147
async def _async_close(self) -> None:
140148
"""Cancel and wait for the cleanup task to finish."""
141149
assert self._setup_task is not None
142-
await self._setup_task
150+
# Setup may have been cancelled by a prior ``_async_shutdown`` (e.g.
151+
# close-before-start); swallow the cancellation so close still runs
152+
# to completion.
153+
with contextlib.suppress(asyncio.CancelledError):
154+
await self._setup_task
143155
self._async_shutdown()
144156
await asyncio.sleep(0) # flush out any call soons
145-
assert self._cleanup_timer is not None
146-
self._cleanup_timer.cancel()
157+
if self._cleanup_timer is not None:
158+
self._cleanup_timer.cancel()
147159

148160
def _async_shutdown(self) -> None:
149161
"""Shutdown transports and sockets."""
150162
assert self.running_future is not None
151163
assert self.loop is not None
152164
self.running_future = self.loop.create_future()
165+
# Stop ``_async_create_endpoints`` from continuing to wrap sockets
166+
# after shutdown has started — otherwise it would create transports
167+
# that nobody closes, leaking FDs.
168+
if self._setup_task is not None and not self._setup_task.done():
169+
self._setup_task.cancel()
153170
for wrapped_transport in itertools.chain(self.senders, self.readers):
154171
wrapped_transport.transport.close()
172+
# Anything still in these collections was never adopted by a
173+
# transport; release the FDs so they don't leak.
174+
if self._listen_socket is not None:
175+
self._listen_socket.close()
176+
self._listen_socket = None
177+
for s in self._respond_sockets:
178+
s.close()
179+
self._respond_sockets = []
155180

156181
def close(self) -> None:
157182
"""Close from sync context.

tests/services/test_browser.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,9 @@ def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de
10511051
if name == registration_name:
10521052
callbacks.append(("remove", type_, name))
10531053

1054+
def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def]
1055+
"""No-op so the browser thread doesn't see ``NotImplementedError``."""
1056+
10541057
listener = MyServiceListener()
10551058

10561059
browser = r.ServiceBrowser(zc, type_, None, listener)

tests/services/test_info.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,6 +1529,7 @@ async def test_bad_ip_addresses_ignored_in_cache():
15291529
info = ServiceInfo(type_, registration_name)
15301530
info.load_from_cache(aiozc.zeroconf)
15311531
assert info.addresses_by_version(IPVersion.V4Only) == [b"\x7f\x00\x00\x01"]
1532+
await aiozc.async_close()
15321533

15331534

15341535
@pytest.mark.asyncio
@@ -1804,6 +1805,7 @@ async def test_address_resolver():
18041805
aiozc.zeroconf.async_send(outgoing)
18051806
assert await resolve_task
18061807
assert resolver.addresses == [b"\x7f\x00\x00\x01"]
1808+
await aiozc.async_close()
18071809

18081810

18091811
@pytest.mark.asyncio
@@ -1828,6 +1830,7 @@ async def test_address_resolver_ipv4():
18281830
aiozc.zeroconf.async_send(outgoing)
18291831
assert await resolve_task
18301832
assert resolver.addresses == [b"\x7f\x00\x00\x01"]
1833+
await aiozc.async_close()
18311834

18321835

18331836
@pytest.mark.asyncio
@@ -1854,6 +1857,7 @@ async def test_address_resolver_ipv6():
18541857
aiozc.zeroconf.async_send(outgoing)
18551858
assert await resolve_task
18561859
assert resolver.ip_addresses_by_version(IPVersion.All) == [ip_address("fe80::52e:c2f2:bc5f:e9c6")]
1860+
await aiozc.async_close()
18571861

18581862

18591863
@pytest.mark.asyncio

tests/test_asyncio.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ async def test_async_service_registration_name_strict_check(quick_timing: None)
500500

501501
await aiozc.async_unregister_service(info)
502502
await aiozc.async_close()
503+
zc.close()
503504

504505

505506
@pytest.mark.asyncio

tests/test_core.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,9 @@ def _background_register():
824824
async def test_event_loop_blocked(mock_start):
825825
"""Test we raise NotRunningException when waiting for startup that times out."""
826826
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
827-
with pytest.raises(NotRunningException):
828-
await aiozc.zeroconf.async_wait_for_start(timeout=0)
829-
assert aiozc.zeroconf.started is False
827+
try:
828+
with pytest.raises(NotRunningException):
829+
await aiozc.zeroconf.async_wait_for_start(timeout=0)
830+
assert aiozc.zeroconf.started is False
831+
finally:
832+
await aiozc.async_close()

tests/test_handlers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,6 +1799,8 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli
17991799
zc.record_manager.async_updates_from_response(incoming)
18001800
assert info2.dns_pointer() in incoming.answers()
18011801

1802+
await aiozc.async_close()
1803+
18021804

18031805
@pytest.mark.asyncio
18041806
async def test_response_aggregation_random_delay():

tests/utils/test_asyncio.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def _run_coro() -> None:
105105

106106
assert loop.is_running() is False
107107
runcoro_thread.join()
108+
loop.close()
108109

109110

110111
def test_cumulative_timeouts_less_than_close_plus_buffer():

0 commit comments

Comments
 (0)