FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(core): close owned event loop on Zeroconf.close() to stop FD leak… · python-zeroconf/python-zeroconf@2f78370 · GitHub

Commit 2f78370

Browse files
authored
fix(core): close owned event loop on Zeroconf.close() to stop FD leak (#1685)
1 parent 277f80d commit 2f78370

3 files changed

Lines changed: 56 additions & 2 deletions

File tree

‎src/zeroconf/_core.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -676,13 +676,22 @@ def _close(self) -> None:
676676

677677
def _shutdown_threads(self) -> None:
678678
"""Shutdown any threads."""
679+
assert self.loop is not None
680+
if self.loop.is_closed():
681+
# close() is documented as idempotent — a second call after the
682+
# loop has been torn down must be a no-op rather than raising.
683+
return
679684
self.notify_all()
680685
if not self._loop_thread:
681686
return
682-
assert self.loop is not None
683687
shutdown_loop(self.loop)
684688
self._loop_thread.join()
685689
self._loop_thread = None
690+
# The loop's selector (epoll FD on Linux) and self-pipe sockets stay
691+
# open until loop.close() is called. We own this loop because
692+
# _start_thread() created it, so close it here to avoid leaking
693+
# those file descriptors across Zeroconf() construct/close cycles.
694+
self.loop.close()
686695

687696
def close(self) -> None:
688697
"""Ends the background threads, and prevent this instance from

‎src/zeroconf/_services/browser.py‎

Lines changed: 11 additions & 1 deletion
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 heapq
2728
import queue
2829
import random
@@ -793,7 +794,16 @@ def cancel(self) -> None:
793794
"""Cancel the browser."""
794795
assert self.zc.loop is not None
795796
self.queue.put(None)
796-
self.zc.loop.call_soon_threadsafe(self._async_cancel)
797+
# While the loop is running, _async_cancel stops the query scheduler
798+
# and cancels the query-sender task — that is the normal cleanup
799+
# path. Skip scheduling solely because the loop is closed: a closed
800+
# loop rejects call_soon_threadsafe with RuntimeError. The
801+
# is_closed() check narrows the common case (loop already closed by
802+
# Zeroconf.close()) without paying for raise/catch; suppress covers
803+
# the residual is_closed() -> call_soon_threadsafe race window.
804+
with contextlib.suppress(RuntimeError):
805+
if not self.zc.loop.is_closed():
806+
self.zc.loop.call_soon_threadsafe(self._async_cancel)
797807
self.join()
798808

799809
def run(self) -> None:

‎tests/test_core.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import unittest
1313
import unittest.mock
1414
import warnings
15+
from pathlib import Path
1516
from typing import cast
1617
from unittest.mock import AsyncMock, Mock, patch
1718

@@ -83,6 +84,40 @@ def test_close_multiple_times(self):
8384
rv.close()
8485
rv.close()
8586

87+
def test_close_releases_owned_event_loop(self):
88+
"""Closing a Zeroconf that started its own loop thread closes that loop.
89+
90+
Regression test for issue #1589 — without loop.close(), the selector
91+
(epoll on Linux) and its self-pipe sockets stay open across each
92+
Zeroconf construct/close cycle and the process eventually exhausts
93+
its FD limit.
94+
"""
95+
rv = r.Zeroconf(interfaces=["127.0.0.1"])
96+
loop = rv.loop
97+
assert loop is not None
98+
assert loop.is_running()
99+
rv.close()
100+
assert loop.is_closed()
101+
102+
@unittest.skipUnless(sys.platform.startswith("linux"), "Requires /proc/<pid>/fd")
103+
@unittest.skipUnless(Path(f"/proc/{os.getpid()}/fd").is_dir(), "/proc/<pid>/fd not available")
104+
def test_close_does_not_leak_file_descriptors(self):
105+
"""Tight loops of Zeroconf()/close() do not leak FDs (issue #1589)."""
106+
fd_dir = Path(f"/proc/{os.getpid()}/fd")
107+
108+
def _fd_count() -> int:
109+
return sum(1 for _ in fd_dir.iterdir())
110+
111+
# Warm-up cycle so any one-shot import-time FDs land before measuring.
112+
r.Zeroconf(interfaces=["127.0.0.1"]).close()
113+
baseline = _fd_count()
114+
for _ in range(10):
115+
r.Zeroconf(interfaces=["127.0.0.1"]).close()
116+
# Allow tiny slack for unrelated FDs the test harness may open
117+
# (e.g. coverage), but reject the per-cycle linear growth pattern
118+
# the bug produced (~3 FDs per cycle, so >=30 over 10 cycles).
119+
assert _fd_count() - baseline < 10
120+
86121
@unittest.skipIf(not has_working_ipv6(), "Requires IPv6")
87122
@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled")
88123
def test_launch_and_close_v4_v6(self):

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL