Skip to content

Commit 78cd600

Browse files
bdracoCopilot
andauthored
Add explicit close method (#166)
Co-authored-by: Copilot <[email protected]>
1 parent f259a6e commit 78cd600

File tree

4 files changed

+413
-24
lines changed

4 files changed

+413
-24
lines changed

README.rst

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ The following query types are supported: A, AAAA, ANY, CAA, CNAME, MX, NAPTR, NS
3535
API
3636
===
3737

38-
The API is pretty simple, three functions are provided in the ``DNSResolver`` class:
38+
The API is pretty simple, the following functions are provided in the ``DNSResolver`` class:
3939

4040
* ``query(host, type)``: Do a DNS resolution of the given type for the given hostname. It returns an
4141
instance of ``asyncio.Future``. The actual result of the DNS query is taken directly from pycares.
@@ -53,6 +53,27 @@ The API is pretty simple, three functions are provided in the ``DNSResolver`` cl
5353
* ``gethostbyaddr(name)``: Make a reverse lookup for an address.
5454
* ``cancel()``: Cancel all pending DNS queries. All futures will get ``DNSError`` exception set, with
5555
``ARES_ECANCELLED`` errno.
56+
* ``close()``: Close the resolver. This releases all resources and cancels any pending queries. It must be called
57+
when the resolver is no longer needed (e.g., application shutdown). The resolver should only be closed from the
58+
event loop that created the resolver.
59+
60+
61+
Async Context Manager Support
62+
=============================
63+
64+
While not recommended for typical use cases, ``DNSResolver`` can be used as an async context manager
65+
for scenarios where automatic cleanup is desired:
66+
67+
.. code:: python
68+
69+
async with aiodns.DNSResolver() as resolver:
70+
result = await resolver.query('example.com', 'A')
71+
# resolver.close() is called automatically when exiting the context
72+
73+
**Important**: This pattern is discouraged for most applications because ``DNSResolver`` instances
74+
are designed to be long-lived and reused for many queries. Creating and destroying resolvers
75+
frequently adds unnecessary overhead. Use the context manager pattern only when you specifically
76+
need automatic cleanup for short-lived resolver instances, such as in tests or one-off scripts.
5677

5778

5879
Note for Windows users

aiodns/__init__.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import socket
55
import sys
66
from collections.abc import Iterable, Sequence
7+
from types import TracebackType
78
from typing import (
89
TYPE_CHECKING,
910
Any,
@@ -66,6 +67,7 @@ def __init__(
6667
loop: Optional[asyncio.AbstractEventLoop] = None,
6768
**kwargs: Any,
6869
) -> None: # TODO(PY311): Use Unpack for kwargs.
70+
self._closed = True
6971
self.loop = loop or asyncio.get_event_loop()
7072
if TYPE_CHECKING:
7173
assert self.loop is not None
@@ -78,6 +80,7 @@ def __init__(
7880
self._read_fds: set[int] = set()
7981
self._write_fds: set[int] = set()
8082
self._timer: Optional[asyncio.TimerHandle] = None
83+
self._closed = False
8184

8285
def _make_channel(self, **kwargs: Any) -> tuple[bool, pycares.Channel]:
8386
if (
@@ -319,3 +322,55 @@ def _start_timer(self) -> None:
319322
timeout = 0.1
320323

321324
self._timer = self.loop.call_later(timeout, self._timer_cb)
325+
326+
def _cleanup(self) -> None:
327+
"""Cleanup timers and file descriptors when closing resolver."""
328+
if self._closed:
329+
return
330+
# Mark as closed first to prevent double cleanup
331+
self._closed = True
332+
# Cancel timer if running
333+
if self._timer is not None:
334+
self._timer.cancel()
335+
self._timer = None
336+
337+
# Remove all file descriptors
338+
for fd in list(self._read_fds):
339+
self.loop.remove_reader(fd)
340+
for fd in list(self._write_fds):
341+
self.loop.remove_writer(fd)
342+
343+
self._read_fds.clear()
344+
self._write_fds.clear()
345+
self._channel.close()
346+
347+
async def close(self) -> None:
348+
"""
349+
Cleanly close the DNS resolver.
350+
351+
This should be called to ensure all resources are properly released.
352+
After calling close(), the resolver should not be used again.
353+
"""
354+
self._cleanup()
355+
356+
async def __aenter__(self) -> 'DNSResolver':
357+
"""Enter the async context manager."""
358+
return self
359+
360+
async def __aexit__(
361+
self,
362+
exc_type: Optional[type[BaseException]],
363+
exc_val: Optional[BaseException],
364+
exc_tb: Optional[TracebackType],
365+
) -> None:
366+
"""Exit the async context manager."""
367+
await self.close()
368+
369+
def __del__(self) -> None:
370+
"""Handle cleanup when the resolver is garbage collected."""
371+
# Check if we have a channel to clean up
372+
# This can happen if an exception occurs during __init__ before
373+
# _channel is created (e.g., RuntimeError on Windows
374+
# without proper loop)
375+
if hasattr(self, '_channel'):
376+
self._cleanup()

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def get_version():
2121
license='MIT',
2222
long_description=codecs.open('README.rst', encoding='utf-8').read(),
2323
long_description_content_type='text/x-rst',
24-
install_requires=['pycares>=4.0.0'],
24+
install_requires=['pycares>=4.9.0'],
2525
packages=['aiodns'],
2626
package_data={'aiodns': ['py.typed']},
2727
platforms=['POSIX', 'Microsoft Windows'],

0 commit comments

Comments
 (0)