| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
Merging this PR will not alter performance✅ 6 untouched benchmarks Comparing bluetoothbot:koan/fix-fd-leak-on-close (7da73d8) with master (277f80d) |
Sorry, something went wrong.
Codecov Report✅ All modified and coverable lines are covered by tests. @@ Coverage Diff @@
## master #1685 +/- ##
=======================================
Coverage 99.76% 99.76%
=======================================
Files 33 33
Lines 3401 3407 +6
Branches 461 463 +2
=======================================
+ Hits 3393 3399 +6
Misses 5 5
Partials 3 3 ☔ View full report in Codecov by Harness.
|
Sorry, something went wrong.
There was a problem hiding this comment.
Fixes a file-descriptor leak when repeatedly constructing and closing Zeroconf() instances that spin up their own background asyncio event loop, by ensuring the owned loop is properly closed during shutdown.
Changes:
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/zeroconf/_core.py | Close owned event loop on shutdown and avoid errors on repeated close() calls. |
| src/zeroconf/_services/browser.py | Prevent ServiceBrowser.cancel() from scheduling onto a closed loop. |
| tests/test_core.py | Add regression tests for owned-loop closure and Linux FD leak detection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Sorry, something went wrong.
Sorry, something went wrong.
PR Review — fix(core): close owned event loop on Zeroconf.close() to stop FD leakThe fix itself is correct and well-scoped: closing the owned loop after the thread joins plugs the FD leak, the idempotency guard preserves the documented Zeroconf.close() contract, and the browser-cancel guard handles the close-after-cancel race. The new tests fail on master and pass on this branch, which is exactly the right shape for a regression test. Before merging, please address @bdraco's contextlib.suppress request in browser.py:803-806 and reword the misleading "nothing left to do" comment that the Copilot bot flagged. The other items (docstring trim, test threshold) are stylistic suggestions, not blockers. 🟢 Suggestions1. Use contextlib.suppress per maintainer request (`src/zeroconf/_services/browser.py`, L803-806)@bdraco requested with contextlib.suppress(RuntimeError): here. It's the project's preferred idiom for narrow exception-swallow blocks and reads more clearly than try/except: pass — the intent ("close-loop race is expected, ignore it") is right there in the with line. Suggested change: with contextlib.suppress(RuntimeError):
if not self.zc.loop.is_closed():
self.zc.loop.call_soon_threadsafe(self._async_cancel)Add import contextlib at the top of the module if it's not already imported. try:
if not self.zc.loop.is_closed():
self.zc.loop.call_soon_threadsafe(self._async_cancel)
except RuntimeError:
pass
The comment claims _async_cancel had "nothing left to do anyway" once Zeroconf.close() detaches the browser. That's misleading — _async_cancel also stops the query scheduler and cancels the query-sender task, which is the normal cleanup path while the loop is running. The skip only happens when the loop is already closed, where scheduling is impossible regardless. Suggested rewording, dropping the inaccurate "nothing to do" claim: # Skip scheduling _async_cancel when the loop is already closed:
# call_soon_threadsafe would raise RuntimeError. The normal path
# (loop running) still uses _async_cancel to stop the query
# scheduler and cancel the query-sender task. is_closed() is racy
# against a concurrent Zeroconf.close(), so suppress RuntimeError
# from call_soon_threadsafe as well.Per the project's docstring/comment guidelines, this is one of the cases where a comment is justified (subtle race, RFC-style invariant), so a precise wording matters. 3. Idempotency comment is redundant with the function contract (`src/zeroconf/_core.py`, L679-684)Per CLAUDE.md's commenting bar, this 3-line comment restates what the early-return + the Zeroconf.close() docstring (one frame up the call stack) already say. A future reader looking at if self.loop.is_closed(): return will infer "second call is a no-op" from the code itself. Consider dropping the comment (or trimming to a single line, e.g. # Second close() — loop already torn down.). The self.loop.close() block at the bottom already has a useful comment that explains the why (selector + self-pipe FDs); that one is worth keeping. if self.loop.is_closed():
# close() is documented as idempotent — a second call after the
# loop has been torn down must be a no-op rather than raising.
return
CLAUDE.md explicitly calls this out: "A test docstring should name what the test pins, in one sentence — not re-explain the bug, the fix, or the surrounding flow." The current docstrings describe the FD-leak mechanism (selector, self-pipe sockets, FD limit exhaustion) — that's PR-description / commit-message territory. Suggested trims: def test_close_releases_owned_event_loop(self):
"""Zeroconf.close() closes the event loop it started itself."""
def test_close_does_not_leak_file_descriptors(self):
"""Repeated Zeroconf()/close() does not grow the process FD table."""The issue-number cross-reference also belongs in the PR body, not the test docstring (per the same section of CLAUDE.md). 5. FD-leak threshold could be tighter (`tests/test_core.py`, L117-121)The threshold _fd_count() - baseline < 10 over 10 cycles allows ~1 FD growth per cycle, which is a lot of slack relative to the bug (~3 FDs per cycle = 30 over 10). If coverage/test harness FDs are the concern, the warm-up cycle already absorbs most one-shots before baseline is captured. A tighter bound like < 5 would still tolerate harness noise but would catch a partial regression (e.g. someone reintroduces just the epoll FD leak but not the self-pipe pair). Optional — current threshold does catch the original bug. assert _fd_count() - baseline < 10 Checklist
SummaryThe fix itself is correct and well-scoped: closing the owned loop after the thread joins plugs the FD leak, the idempotency guard preserves the documented Zeroconf.close() contract, and the browser-cancel guard handles the close-after-cancel race. The new tests fail on master and pass on this branch, which is exactly the right shape for a regression test. Before merging, please address @bdraco's contextlib.suppress request in browser.py:803-806 and reword the misleading "nothing left to do" comment that the Copilot bot flagged. The other items (docstring trim, test threshold) are stylistic suggestions, not blockers. |
Sorry, something went wrong.
Each Zeroconf() instance constructed without an active asyncio loop spins up a private loop in a daemon thread. _shutdown_threads() stopped that loop but never called loop.close(), so the selector (epoll fd on Linux) and the loop's self-pipe sockets stayed open for the lifetime of the process. Tight construct/close cycles (e.g. an mDNS reflector that recycles its socket set) exhausted the process FD limit. Close the loop after the thread joins, and guard _shutdown_threads() + ServiceBrowser.cancel() against a re-entry on the now-closed loop so the documented "close is idempotent" contract still holds. Closes python-zeroconf#1589.
Rebase with requested adjustmentsBranch koan/fix-fd-leak-on-close was rebased onto master and review feedback was applied. Changes applied
Stats3 files changed, 51 insertions(+), 2 deletions(-)
CI statusCI will be checked asynchronously. Automated by Kōan |
Sorry, something went wrong.
Sorry, something went wrong.
Rebase with requested adjustmentsBranch koan/fix-fd-leak-on-close was rebased onto master and review feedback was applied. Changes applied
Stats3 files changed, 56 insertions(+), 2 deletions(-)
CI statusCI will be checked asynchronously. Automated by Kōan |
Sorry, something went wrong.
Sorry, something went wrong.
Rebase with requested adjustmentsBranch koan/fix-fd-leak-on-close was rebased onto master and review feedback was applied. Changes applied
Stats3 files changed, 56 insertions(+), 2 deletions(-)
CI statusCI will be checked asynchronously. Automated by Kōan |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
What
Stop leaking file descriptors when a process repeatedly constructs and closes Zeroconf instances.
Why
Closes #1589. Each Zeroconf() built outside an existing asyncio loop spawns a private loop in a daemon thread. _shutdown_threads() previously called loop.stop() (via shutdown_loop) but never loop.close(), so the loop's selector (epoll FD on Linux) and its self-pipe sockets stayed open. Reproducer from the issue (20 cycles, before/after):
Long-running services that recycle their socket set (the reflector use case the reporter described) eventually hit OSError: [Errno 24] Too many open files.
How
Testing
Quality Report
Changes: 3 files changed, 50 insertions(+), 2 deletions(-)
Code scan: clean
Tests: failed (timeout (120s))
Branch hygiene: clean
Generated by Kōan post-mission quality pipeline