| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -676,13 +676,22 @@ def _close(self) -> None: | |||
| 676 | 676 | ||
| 677 | 677 | def _shutdown_threads(self) -> None: | |
| 678 | 678 | """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 | ||
| 679 | 684 | self.notify_all() | |
| 680 | 685 | if not self._loop_thread: | |
| 681 | 686 | return | |
| 682 | - assert self.loop is not None | ||
| 683 | 687 | shutdown_loop(self.loop) | |
| 684 | 688 | self._loop_thread.join() | |
| 685 | 689 | 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() | ||
| 686 | 695 | ||
| 687 | 696 | def close(self) -> None: | |
| 688 | 697 | """Ends the background threads, and prevent this instance from | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -23,6 +23,7 @@ | |||
| 23 | 23 | from __future__ import annotations | |
| 24 | 24 | ||
| 25 | 25 | import asyncio | |
| 26 | + import contextlib | ||
| 26 | 27 | import heapq | |
| 27 | 28 | import queue | |
| 28 | 29 | import random | |
@@ -793,7 +794,16 @@ def cancel(self) -> None: | |||
| 793 | 794 | """Cancel the browser.""" | |
| 794 | 795 | assert self.zc.loop is not None | |
| 795 | 796 | 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) | ||
| 797 | 807 | self.join() | |
| 798 | 808 | ||
| 799 | 809 | def run(self) -> None: | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -12,6 +12,7 @@ | |||
| 12 | 12 | import unittest | |
| 13 | 13 | import unittest.mock | |
| 14 | 14 | import warnings | |
| 15 | + from pathlib import Path | ||
| 15 | 16 | from typing import cast | |
| 16 | 17 | from unittest.mock import AsyncMock, Mock, patch | |
| 17 | 18 | ||
@@ -83,6 +84,40 @@ def test_close_multiple_times(self): | |||
| 83 | 84 | rv.close() | |
| 84 | 85 | rv.close() | |
| 85 | 86 | ||
| 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 | + | ||
| 86 | 121 | @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") | |
| 87 | 122 | @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") | |
| 88 | 123 | def test_launch_and_close_v4_v6(self): | |
| Back | FazBrowse Home | New Git URL |
0 commit comments