Skip to content

Commit ebfd7d7

Browse files
bdracosaghul
authored andcommitted
Fix shutdown race
1 parent ca0a4c9 commit ebfd7d7

11 files changed

+822
-48
lines changed

docs/channel.rst

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,36 @@
6666

6767
The c-ares ``Channel`` provides asynchronous DNS operations.
6868

69+
The Channel object is designed to handle an unlimited number of DNS queries efficiently.
70+
Creating and destroying resolver instances repeatedly is resource-intensive and not
71+
recommended. Instead, create a single resolver instance and reuse it throughout your
72+
application's lifetime.
73+
74+
.. important::
75+
It is recommended to explicitly close channels when done for predictable resource
76+
cleanup. Use :py:meth:`close` which can be called from any thread.
77+
While channels will attempt automatic cleanup during garbage collection, explicit
78+
closing is safer as it gives you control over when resources are released.
79+
80+
.. warning::
81+
The channel destruction mechanism has a limited throughput of 60 channels per minute
82+
(one channel per second) to ensure thread safety and prevent use-after-free errors
83+
in c-ares. This means:
84+
85+
- Avoid creating transient channels for individual queries
86+
- Reuse channel instances whenever possible
87+
- For applications with high query volume, use a single long-lived channel
88+
- If you must create multiple channels, consider pooling them
89+
90+
Creating and destroying channels rapidly will result in a backlog as the destruction
91+
queue processes channels sequentially with a 1-second delay between each.
92+
93+
The Channel class supports the context manager protocol for automatic cleanup::
94+
95+
with pycares.Channel() as channel:
96+
channel.query('example.com', pycares.QUERY_TYPE_A, callback)
97+
# Channel is automatically closed when exiting the context
98+
6999
.. py:method:: getaddrinfo(host, port, callback, family=0, type=0, proto=0, flags=0)
70100
71101
:param string host: Hostname to resolve.
@@ -243,8 +273,33 @@
243273
244274
Cancel any pending query on this channel. All pending callbacks will be called with ARES_ECANCELLED errorno.
245275

276+
.. py:method:: close()
277+
278+
Close the channel as soon as it's safe to do so.
279+
280+
This method can be called from any thread. The channel will be destroyed
281+
safely using a background thread with a 1-second delay to ensure c-ares
282+
has completed its cleanup.
283+
284+
Once close() is called, no new queries can be started. Any pending
285+
queries will be cancelled and their callbacks will receive ARES_ECANCELLED.
286+
287+
.. note::
288+
It is recommended to explicitly call :py:meth:`close` rather than
289+
relying on garbage collection. Explicit closing provides:
290+
291+
- Control over when resources are released
292+
- Predictable shutdown timing
293+
- Proper cleanup of all resources
294+
295+
While the channel will attempt cleanup during garbage collection,
296+
explicit closing is safer and more predictable.
297+
298+
.. versionadded:: 4.9.0
299+
300+
246301
.. py:method:: reinit()
247-
302+
248303
Reinitialize the channel.
249304

250305
For more details, see the `ares_reinit documentation <https://c-ares.org/docs/ares_reinit.html>`_.
@@ -284,4 +339,3 @@
284339
.. py:attribute:: servers
285340
286341
List of nameservers to use for DNS queries.
287-
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import asyncio
2+
import socket
3+
from typing import Any, Callable, Optional
4+
5+
import pycares
6+
7+
8+
class DNSResolver:
9+
def __init__(self, loop: Optional[asyncio.AbstractEventLoop] = None) -> None:
10+
# Use event_thread=True for automatic event handling in a separate thread
11+
self._channel = pycares.Channel(event_thread=True)
12+
self.loop = loop or asyncio.get_running_loop()
13+
14+
def query(
15+
self, name: str, query_type: int, cb: Callable[[Any, Optional[int]], None]
16+
) -> None:
17+
self._channel.query(name, query_type, cb)
18+
19+
def gethostbyname(
20+
self, name: str, cb: Callable[[Any, Optional[int]], None]
21+
) -> None:
22+
self._channel.gethostbyname(name, socket.AF_INET, cb)
23+
24+
def close(self) -> None:
25+
"""Thread-safe shutdown of the channel."""
26+
# Simply call close() - it's thread-safe and handles everything
27+
self._channel.close()
28+
29+
30+
async def main() -> None:
31+
# Track queries
32+
query_count = 0
33+
completed_count = 0
34+
cancelled_count = 0
35+
36+
def cb(query_name: str) -> Callable[[Any, Optional[int]], None]:
37+
def _cb(result: Any, error: Optional[int]) -> None:
38+
nonlocal completed_count, cancelled_count
39+
if error == pycares.errno.ARES_ECANCELLED:
40+
cancelled_count += 1
41+
print(f"Query for {query_name} was CANCELLED")
42+
else:
43+
completed_count += 1
44+
print(
45+
f"Query for {query_name} completed - Result: {result}, Error: {error}"
46+
)
47+
48+
return _cb
49+
50+
loop = asyncio.get_running_loop()
51+
resolver = DNSResolver(loop)
52+
53+
print("=== Starting first batch of queries ===")
54+
# First batch - these should complete
55+
resolver.query("google.com", pycares.QUERY_TYPE_A, cb("google.com"))
56+
resolver.query("cloudflare.com", pycares.QUERY_TYPE_A, cb("cloudflare.com"))
57+
query_count += 2
58+
59+
# Give them a moment to complete
60+
await asyncio.sleep(0.5)
61+
62+
print("\n=== Starting second batch of queries (will be cancelled) ===")
63+
# Second batch - these will be cancelled
64+
resolver.query("github.com", pycares.QUERY_TYPE_A, cb("github.com"))
65+
resolver.query("stackoverflow.com", pycares.QUERY_TYPE_A, cb("stackoverflow.com"))
66+
resolver.gethostbyname("python.org", cb("python.org"))
67+
query_count += 3
68+
69+
# Immediately close - this will cancel pending queries
70+
print("\n=== Closing resolver (cancelling pending queries) ===")
71+
resolver.close()
72+
print("Resolver closed successfully")
73+
74+
print(f"\n=== Summary ===")
75+
print(f"Total queries: {query_count}")
76+
print(f"Completed: {completed_count}")
77+
print(f"Cancelled: {cancelled_count}")
78+
79+
80+
if __name__ == "__main__":
81+
# Check if c-ares supports threads
82+
if pycares.ares_threadsafety():
83+
# For Python 3.7+
84+
asyncio.run(main())
85+
else:
86+
print("c-ares was not compiled with thread support")
87+
print("Please see examples/cares-asyncio.py for sock_state_cb usage")

examples/cares-asyncio.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,38 @@ def query(self, query_type, name, cb):
5252
def gethostbyname(self, name, cb):
5353
self._channel.gethostbyname(name, socket.AF_INET, cb)
5454

55+
def close(self):
56+
"""Close the resolver and cleanup resources."""
57+
if self._timer:
58+
self._timer.cancel()
59+
self._timer = None
60+
for fd in self._fds:
61+
self.loop.remove_reader(fd)
62+
self.loop.remove_writer(fd)
63+
self._fds.clear()
64+
# Note: The channel will be destroyed safely in a background thread
65+
# with a 1-second delay to ensure c-ares has completed its cleanup.
66+
self._channel.close()
5567

56-
def main():
68+
69+
async def main():
5770
def cb(result, error):
5871
print("Result: {}, Error: {}".format(result, error))
59-
loop = asyncio.get_event_loop()
72+
73+
loop = asyncio.get_running_loop()
6074
resolver = DNSResolver(loop)
61-
resolver.query('google.com', pycares.QUERY_TYPE_A, cb)
62-
resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
63-
resolver.gethostbyname('apple.com', cb)
64-
loop.run_forever()
75+
76+
try:
77+
resolver.query('google.com', pycares.QUERY_TYPE_A, cb)
78+
resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
79+
resolver.gethostbyname('apple.com', cb)
80+
81+
# Give some time for queries to complete
82+
await asyncio.sleep(2)
83+
finally:
84+
resolver.close()
6585

6686

6787
if __name__ == '__main__':
68-
main()
88+
asyncio.run(main())
6989

examples/cares-context-manager.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env python
2+
"""
3+
Example of using pycares Channel as a context manager with event_thread=True.
4+
5+
This demonstrates the simplest way to use pycares with automatic cleanup.
6+
The event thread handles all socket operations internally, and the context
7+
manager ensures the channel is properly closed when done.
8+
"""
9+
10+
import pycares
11+
import socket
12+
import time
13+
14+
15+
def main():
16+
"""Run DNS queries using Channel as a context manager."""
17+
results = []
18+
19+
def callback(result, error):
20+
"""Store results from DNS queries."""
21+
if error:
22+
print(f"Error {error}: {pycares.errno.strerror(error)}")
23+
else:
24+
print(f"Result: {result}")
25+
results.append((result, error))
26+
27+
# Use Channel as a context manager with event_thread=True
28+
# This is the recommended pattern for simple use cases
29+
with pycares.Channel(
30+
servers=["8.8.8.8", "8.8.4.4"], timeout=5.0, tries=3, event_thread=True
31+
) as channel:
32+
print("=== Making DNS queries ===")
33+
34+
# Query for A records
35+
channel.query("google.com", pycares.QUERY_TYPE_A, callback)
36+
channel.query("cloudflare.com", pycares.QUERY_TYPE_A, callback)
37+
38+
# Query for AAAA records
39+
channel.query("google.com", pycares.QUERY_TYPE_AAAA, callback)
40+
41+
# Query for MX records
42+
channel.query("python.org", pycares.QUERY_TYPE_MX, callback)
43+
44+
# Query for TXT records
45+
channel.query("google.com", pycares.QUERY_TYPE_TXT, callback)
46+
47+
# Query using gethostbyname
48+
channel.gethostbyname("github.com", socket.AF_INET, callback)
49+
50+
# Query using gethostbyaddr
51+
channel.gethostbyaddr("8.8.8.8", callback)
52+
53+
print("\nWaiting for queries to complete...")
54+
# Give queries time to complete
55+
# In a real application, you would coordinate with your event loop
56+
time.sleep(2)
57+
58+
# Channel is automatically closed when exiting the context
59+
print("\n=== Channel closed automatically ===")
60+
61+
print(f"\nCompleted {len(results)} queries")
62+
63+
# Demonstrate that the channel is closed and can't be used
64+
try:
65+
channel.query("example.com", pycares.QUERY_TYPE_A, callback)
66+
except RuntimeError as e:
67+
print(f"\nExpected error when using closed channel: {e}")
68+
69+
70+
if __name__ == "__main__":
71+
# Check if c-ares supports threads
72+
if pycares.ares_threadsafety():
73+
print(f"Using pycares {pycares.__version__} with c-ares {pycares.ARES_VERSION}")
74+
print(
75+
f"Thread safety: {'enabled' if pycares.ares_threadsafety() else 'disabled'}\n"
76+
)
77+
main()
78+
else:
79+
print("This example requires c-ares to be compiled with thread support")
80+
print("Use cares-select.py or cares-asyncio.py instead")

examples/cares-poll.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ def query(self, query_type, name, cb):
4848
def gethostbyname(self, name, cb):
4949
self._channel.gethostbyname(name, socket.AF_INET, cb)
5050

51+
def close(self):
52+
"""Close the resolver and cleanup resources."""
53+
for fd in list(self._fd_map):
54+
self.poll.unregister(fd)
55+
self._fd_map.clear()
56+
self._channel.close()
57+
5158

5259
if __name__ == '__main__':
5360
def query_cb(result, error):
@@ -57,8 +64,11 @@ def gethostbyname_cb(result, error):
5764
print(result)
5865
print(error)
5966
resolver = DNSResolver()
60-
resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
61-
resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
62-
resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
63-
resolver.gethostbyname('apple.com', gethostbyname_cb)
64-
resolver.wait_channel()
67+
try:
68+
resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
69+
resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
70+
resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
71+
resolver.gethostbyname('apple.com', gethostbyname_cb)
72+
resolver.wait_channel()
73+
finally:
74+
resolver.close()

examples/cares-resolver.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ def query(self, query_type, name, cb):
5252
def gethostbyname(self, name, cb):
5353
self._channel.gethostbyname(name, socket.AF_INET, cb)
5454

55+
def close(self):
56+
"""Close the resolver and cleanup resources."""
57+
self._timer.stop()
58+
for handle in self._fd_map.values():
59+
handle.close()
60+
self._fd_map.clear()
61+
self._channel.close()
62+
5563

5664
if __name__ == '__main__':
5765
def query_cb(result, error):
@@ -62,8 +70,11 @@ def gethostbyname_cb(result, error):
6270
print(error)
6371
loop = pyuv.Loop.default_loop()
6472
resolver = DNSResolver(loop)
65-
resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
66-
resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
67-
resolver.gethostbyname('apple.com', gethostbyname_cb)
68-
loop.run()
73+
try:
74+
resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
75+
resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
76+
resolver.gethostbyname('apple.com', gethostbyname_cb)
77+
loop.run()
78+
finally:
79+
resolver.close()
6980

examples/cares-select.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ def cb(result, error):
2525
print(result)
2626
print(error)
2727
channel = pycares.Channel()
28-
channel.gethostbyname('google.com', socket.AF_INET, cb)
29-
channel.query('google.com', pycares.QUERY_TYPE_A, cb)
30-
channel.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
31-
wait_channel(channel)
28+
try:
29+
channel.gethostbyname('google.com', socket.AF_INET, cb)
30+
channel.query('google.com', pycares.QUERY_TYPE_A, cb)
31+
channel.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
32+
wait_channel(channel)
33+
finally:
34+
channel.close()
3235
print('Done!')
3336

0 commit comments

Comments
 (0)