summaryrefslogtreecommitdiffstats
path: root/meta-python
diff options
context:
space:
mode:
authorJiaying Song <jiaying.song.cn@windriver.com>2025-07-03 13:28:24 +0800
committerArmin Kuster <akuster808@gmail.com>2025-07-06 19:23:22 -0400
commit32200384c737234abf5ef1bbd6825095298e589a (patch)
treea5c47b6b5acd1e002824d17ddccb16a28add75bb /meta-python
parent6b9b9658e67e14b20cee33572f5dac0ec02e5496 (diff)
downloadmeta-openembedded-32200384c737234abf5ef1bbd6825095298e589a.tar.gz
python3-pycares: fix CVE-2025-48945
pycares is a Python module which provides an interface to c-ares. c-ares is a C library that performs DNS requests and name resolutions asynchronously. Prior to version 4.9.0, pycares is vulnerable to a use-after-free condition that occurs when a Channel object is garbage collected while DNS queries are still pending. This results in a fatal Python error and interpreter crash. The vulnerability has been fixed in pycares 4.9.0 by implementing a safe channel destruction mechanism. References: https://nvd.nist.gov/vuln/detail/CVE-2025-48945 Signed-off-by: Jiaying Song <jiaying.song.cn@windriver.com> Signed-off-by: Armin Kuster <akuster808@gmail.com>
Diffstat (limited to 'meta-python')
-rw-r--r--meta-python/recipes-devtools/python/python3-pycares/CVE-2025-48945.patch733
-rw-r--r--meta-python/recipes-devtools/python/python3-pycares_4.6.0.bb1
2 files changed, 734 insertions, 0 deletions
diff --git a/meta-python/recipes-devtools/python/python3-pycares/CVE-2025-48945.patch b/meta-python/recipes-devtools/python/python3-pycares/CVE-2025-48945.patch
new file mode 100644
index 0000000000..10f6fb8ce1
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-pycares/CVE-2025-48945.patch
@@ -0,0 +1,733 @@
1From c9ac3072ad33cc3678fc451c720e2593770d6c5c Mon Sep 17 00:00:00 2001
2From: "J. Nick Koston" <nick@koston.org>
3Date: Thu, 12 Jun 2025 08:57:19 -0500
4Subject: [PATCH] Fix shutdown race
5
6CVE: CVE-2025-48945
7
8Upstream-Status: Backport
9[https://github.com/saghul/pycares/commit/ebfd7d71eb8e74bc1057a361ea79a5906db510d4]
10
11Signed-off-by: Jiaying Song <jiaying.song.cn@windriver.com>
12---
13 examples/cares-asyncio-event-thread.py | 87 ++++++++++++
14 examples/cares-asyncio.py | 34 ++++-
15 examples/cares-context-manager.py | 80 +++++++++++
16 examples/cares-poll.py | 20 ++-
17 examples/cares-resolver.py | 19 ++-
18 examples/cares-select.py | 11 +-
19 examples/cares-selectors.py | 23 ++-
20 src/pycares/__init__.py | 185 ++++++++++++++++++++++---
21 tests/shutdown_at_exit_script.py | 18 +++
22 9 files changed, 431 insertions(+), 46 deletions(-)
23 create mode 100644 examples/cares-asyncio-event-thread.py
24 create mode 100644 examples/cares-context-manager.py
25 create mode 100644 tests/shutdown_at_exit_script.py
26
27diff --git a/examples/cares-asyncio-event-thread.py b/examples/cares-asyncio-event-thread.py
28new file mode 100644
29index 0000000..84c6854
30--- /dev/null
31+++ b/examples/cares-asyncio-event-thread.py
32@@ -0,0 +1,87 @@
33+import asyncio
34+import socket
35+from typing import Any, Callable, Optional
36+
37+import pycares
38+
39+
40+class DNSResolver:
41+ def __init__(self, loop: Optional[asyncio.AbstractEventLoop] = None) -> None:
42+ # Use event_thread=True for automatic event handling in a separate thread
43+ self._channel = pycares.Channel(event_thread=True)
44+ self.loop = loop or asyncio.get_running_loop()
45+
46+ def query(
47+ self, name: str, query_type: int, cb: Callable[[Any, Optional[int]], None]
48+ ) -> None:
49+ self._channel.query(name, query_type, cb)
50+
51+ def gethostbyname(
52+ self, name: str, cb: Callable[[Any, Optional[int]], None]
53+ ) -> None:
54+ self._channel.gethostbyname(name, socket.AF_INET, cb)
55+
56+ def close(self) -> None:
57+ """Thread-safe shutdown of the channel."""
58+ # Simply call close() - it's thread-safe and handles everything
59+ self._channel.close()
60+
61+
62+async def main() -> None:
63+ # Track queries
64+ query_count = 0
65+ completed_count = 0
66+ cancelled_count = 0
67+
68+ def cb(query_name: str) -> Callable[[Any, Optional[int]], None]:
69+ def _cb(result: Any, error: Optional[int]) -> None:
70+ nonlocal completed_count, cancelled_count
71+ if error == pycares.errno.ARES_ECANCELLED:
72+ cancelled_count += 1
73+ print(f"Query for {query_name} was CANCELLED")
74+ else:
75+ completed_count += 1
76+ print(
77+ f"Query for {query_name} completed - Result: {result}, Error: {error}"
78+ )
79+
80+ return _cb
81+
82+ loop = asyncio.get_running_loop()
83+ resolver = DNSResolver(loop)
84+
85+ print("=== Starting first batch of queries ===")
86+ # First batch - these should complete
87+ resolver.query("google.com", pycares.QUERY_TYPE_A, cb("google.com"))
88+ resolver.query("cloudflare.com", pycares.QUERY_TYPE_A, cb("cloudflare.com"))
89+ query_count += 2
90+
91+ # Give them a moment to complete
92+ await asyncio.sleep(0.5)
93+
94+ print("\n=== Starting second batch of queries (will be cancelled) ===")
95+ # Second batch - these will be cancelled
96+ resolver.query("github.com", pycares.QUERY_TYPE_A, cb("github.com"))
97+ resolver.query("stackoverflow.com", pycares.QUERY_TYPE_A, cb("stackoverflow.com"))
98+ resolver.gethostbyname("python.org", cb("python.org"))
99+ query_count += 3
100+
101+ # Immediately close - this will cancel pending queries
102+ print("\n=== Closing resolver (cancelling pending queries) ===")
103+ resolver.close()
104+ print("Resolver closed successfully")
105+
106+ print(f"\n=== Summary ===")
107+ print(f"Total queries: {query_count}")
108+ print(f"Completed: {completed_count}")
109+ print(f"Cancelled: {cancelled_count}")
110+
111+
112+if __name__ == "__main__":
113+ # Check if c-ares supports threads
114+ if pycares.ares_threadsafety():
115+ # For Python 3.7+
116+ asyncio.run(main())
117+ else:
118+ print("c-ares was not compiled with thread support")
119+ print("Please see examples/cares-asyncio.py for sock_state_cb usage")
120diff --git a/examples/cares-asyncio.py b/examples/cares-asyncio.py
121index 0dbd0d2..e73de72 100644
122--- a/examples/cares-asyncio.py
123+++ b/examples/cares-asyncio.py
124@@ -52,18 +52,38 @@ class DNSResolver(object):
125 def gethostbyname(self, name, cb):
126 self._channel.gethostbyname(name, socket.AF_INET, cb)
127
128+ def close(self):
129+ """Close the resolver and cleanup resources."""
130+ if self._timer:
131+ self._timer.cancel()
132+ self._timer = None
133+ for fd in self._fds:
134+ self.loop.remove_reader(fd)
135+ self.loop.remove_writer(fd)
136+ self._fds.clear()
137+ # Note: The channel will be destroyed safely in a background thread
138+ # with a 1-second delay to ensure c-ares has completed its cleanup.
139+ self._channel.close()
140
141-def main():
142+
143+async def main():
144 def cb(result, error):
145 print("Result: {}, Error: {}".format(result, error))
146- loop = asyncio.get_event_loop()
147+
148+ loop = asyncio.get_running_loop()
149 resolver = DNSResolver(loop)
150- resolver.query('google.com', pycares.QUERY_TYPE_A, cb)
151- resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
152- resolver.gethostbyname('apple.com', cb)
153- loop.run_forever()
154+
155+ try:
156+ resolver.query('google.com', pycares.QUERY_TYPE_A, cb)
157+ resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
158+ resolver.gethostbyname('apple.com', cb)
159+
160+ # Give some time for queries to complete
161+ await asyncio.sleep(2)
162+ finally:
163+ resolver.close()
164
165
166 if __name__ == '__main__':
167- main()
168+ asyncio.run(main())
169
170diff --git a/examples/cares-context-manager.py b/examples/cares-context-manager.py
171new file mode 100644
172index 0000000..cb597b2
173--- /dev/null
174+++ b/examples/cares-context-manager.py
175@@ -0,0 +1,80 @@
176+#!/usr/bin/env python
177+"""
178+Example of using pycares Channel as a context manager with event_thread=True.
179+
180+This demonstrates the simplest way to use pycares with automatic cleanup.
181+The event thread handles all socket operations internally, and the context
182+manager ensures the channel is properly closed when done.
183+"""
184+
185+import pycares
186+import socket
187+import time
188+
189+
190+def main():
191+ """Run DNS queries using Channel as a context manager."""
192+ results = []
193+
194+ def callback(result, error):
195+ """Store results from DNS queries."""
196+ if error:
197+ print(f"Error {error}: {pycares.errno.strerror(error)}")
198+ else:
199+ print(f"Result: {result}")
200+ results.append((result, error))
201+
202+ # Use Channel as a context manager with event_thread=True
203+ # This is the recommended pattern for simple use cases
204+ with pycares.Channel(
205+ servers=["8.8.8.8", "8.8.4.4"], timeout=5.0, tries=3, event_thread=True
206+ ) as channel:
207+ print("=== Making DNS queries ===")
208+
209+ # Query for A records
210+ channel.query("google.com", pycares.QUERY_TYPE_A, callback)
211+ channel.query("cloudflare.com", pycares.QUERY_TYPE_A, callback)
212+
213+ # Query for AAAA records
214+ channel.query("google.com", pycares.QUERY_TYPE_AAAA, callback)
215+
216+ # Query for MX records
217+ channel.query("python.org", pycares.QUERY_TYPE_MX, callback)
218+
219+ # Query for TXT records
220+ channel.query("google.com", pycares.QUERY_TYPE_TXT, callback)
221+
222+ # Query using gethostbyname
223+ channel.gethostbyname("github.com", socket.AF_INET, callback)
224+
225+ # Query using gethostbyaddr
226+ channel.gethostbyaddr("8.8.8.8", callback)
227+
228+ print("\nWaiting for queries to complete...")
229+ # Give queries time to complete
230+ # In a real application, you would coordinate with your event loop
231+ time.sleep(2)
232+
233+ # Channel is automatically closed when exiting the context
234+ print("\n=== Channel closed automatically ===")
235+
236+ print(f"\nCompleted {len(results)} queries")
237+
238+ # Demonstrate that the channel is closed and can't be used
239+ try:
240+ channel.query("example.com", pycares.QUERY_TYPE_A, callback)
241+ except RuntimeError as e:
242+ print(f"\nExpected error when using closed channel: {e}")
243+
244+
245+if __name__ == "__main__":
246+ # Check if c-ares supports threads
247+ if pycares.ares_threadsafety():
248+ print(f"Using pycares {pycares.__version__} with c-ares {pycares.ARES_VERSION}")
249+ print(
250+ f"Thread safety: {'enabled' if pycares.ares_threadsafety() else 'disabled'}\n"
251+ )
252+ main()
253+ else:
254+ print("This example requires c-ares to be compiled with thread support")
255+ print("Use cares-select.py or cares-asyncio.py instead")
256diff --git a/examples/cares-poll.py b/examples/cares-poll.py
257index e2796eb..a4ddbd7 100644
258--- a/examples/cares-poll.py
259+++ b/examples/cares-poll.py
260@@ -48,6 +48,13 @@ class DNSResolver(object):
261 def gethostbyname(self, name, cb):
262 self._channel.gethostbyname(name, socket.AF_INET, cb)
263
264+ def close(self):
265+ """Close the resolver and cleanup resources."""
266+ for fd in list(self._fd_map):
267+ self.poll.unregister(fd)
268+ self._fd_map.clear()
269+ self._channel.close()
270+
271
272 if __name__ == '__main__':
273 def query_cb(result, error):
274@@ -57,8 +64,11 @@ if __name__ == '__main__':
275 print(result)
276 print(error)
277 resolver = DNSResolver()
278- resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
279- resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
280- resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
281- resolver.gethostbyname('apple.com', gethostbyname_cb)
282- resolver.wait_channel()
283+ try:
284+ resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
285+ resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
286+ resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
287+ resolver.gethostbyname('apple.com', gethostbyname_cb)
288+ resolver.wait_channel()
289+ finally:
290+ resolver.close()
291diff --git a/examples/cares-resolver.py b/examples/cares-resolver.py
292index 5b4c302..95afeeb 100644
293--- a/examples/cares-resolver.py
294+++ b/examples/cares-resolver.py
295@@ -52,6 +52,14 @@ class DNSResolver(object):
296 def gethostbyname(self, name, cb):
297 self._channel.gethostbyname(name, socket.AF_INET, cb)
298
299+ def close(self):
300+ """Close the resolver and cleanup resources."""
301+ self._timer.stop()
302+ for handle in self._fd_map.values():
303+ handle.close()
304+ self._fd_map.clear()
305+ self._channel.close()
306+
307
308 if __name__ == '__main__':
309 def query_cb(result, error):
310@@ -62,8 +70,11 @@ if __name__ == '__main__':
311 print(error)
312 loop = pyuv.Loop.default_loop()
313 resolver = DNSResolver(loop)
314- resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
315- resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
316- resolver.gethostbyname('apple.com', gethostbyname_cb)
317- loop.run()
318+ try:
319+ resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
320+ resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
321+ resolver.gethostbyname('apple.com', gethostbyname_cb)
322+ loop.run()
323+ finally:
324+ resolver.close()
325
326diff --git a/examples/cares-select.py b/examples/cares-select.py
327index 24bb407..dd8301c 100644
328--- a/examples/cares-select.py
329+++ b/examples/cares-select.py
330@@ -25,9 +25,12 @@ if __name__ == '__main__':
331 print(result)
332 print(error)
333 channel = pycares.Channel()
334- channel.gethostbyname('google.com', socket.AF_INET, cb)
335- channel.query('google.com', pycares.QUERY_TYPE_A, cb)
336- channel.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
337- wait_channel(channel)
338+ try:
339+ channel.gethostbyname('google.com', socket.AF_INET, cb)
340+ channel.query('google.com', pycares.QUERY_TYPE_A, cb)
341+ channel.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
342+ wait_channel(channel)
343+ finally:
344+ channel.close()
345 print('Done!')
346
347diff --git a/examples/cares-selectors.py b/examples/cares-selectors.py
348index 6b55520..fbb2f2d 100644
349--- a/examples/cares-selectors.py
350+++ b/examples/cares-selectors.py
351@@ -47,6 +47,14 @@ class DNSResolver(object):
352 def gethostbyname(self, name, cb):
353 self._channel.gethostbyname(name, socket.AF_INET, cb)
354
355+ def close(self):
356+ """Close the resolver and cleanup resources."""
357+ for fd in list(self._fd_map):
358+ self.poll.unregister(fd)
359+ self._fd_map.clear()
360+ self.poll.close()
361+ self._channel.close()
362+
363
364 if __name__ == '__main__':
365 def query_cb(result, error):
366@@ -56,10 +64,13 @@ if __name__ == '__main__':
367 print(result)
368 print(error)
369 resolver = DNSResolver()
370- resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
371- resolver.query('google.com', pycares.QUERY_TYPE_AAAA, query_cb)
372- resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
373- resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
374- resolver.gethostbyname('apple.com', gethostbyname_cb)
375- resolver.wait_channel()
376+ try:
377+ resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
378+ resolver.query('google.com', pycares.QUERY_TYPE_AAAA, query_cb)
379+ resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
380+ resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
381+ resolver.gethostbyname('apple.com', gethostbyname_cb)
382+ resolver.wait_channel()
383+ finally:
384+ resolver.close()
385
386diff --git a/src/pycares/__init__.py b/src/pycares/__init__.py
387index 26d82ab..596cd4b 100644
388--- a/src/pycares/__init__.py
389+++ b/src/pycares/__init__.py
390@@ -11,10 +11,13 @@ from ._version import __version__
391
392 import socket
393 import math
394-import functools
395-import sys
396+import threading
397+import time
398+import weakref
399 from collections.abc import Callable, Iterable
400-from typing import Any, Optional, Union
401+from contextlib import suppress
402+from typing import Any, Callable, Optional, Dict, Union
403+from queue import SimpleQueue
404
405 IP4 = tuple[str, int]
406 IP6 = tuple[str, int, int, int]
407@@ -80,17 +83,25 @@ class AresError(Exception):
408
409 # callback helpers
410
411-_global_set = set()
412+_handle_to_channel: Dict[Any, "Channel"] = {} # Maps handle to channel to prevent use-after-free
413+
414
415 @_ffi.def_extern()
416 def _sock_state_cb(data, socket_fd, readable, writable):
417+ # Note: sock_state_cb handle is not tracked in _handle_to_channel
418+ # because it has a different lifecycle (tied to the channel, not individual queries)
419+ if _ffi is None:
420+ return
421 sock_state_cb = _ffi.from_handle(data)
422 sock_state_cb(socket_fd, readable, writable)
423
424 @_ffi.def_extern()
425 def _host_cb(arg, status, timeouts, hostent):
426+ # Get callback data without removing the reference yet
427+ if _ffi is None or arg not in _handle_to_channel:
428+ return
429+
430 callback = _ffi.from_handle(arg)
431- _global_set.discard(arg)
432
433 if status != _lib.ARES_SUCCESS:
434 result = None
435@@ -99,11 +110,15 @@ def _host_cb(arg, status, timeouts, hostent):
436 status = None
437
438 callback(result, status)
439+ _handle_to_channel.pop(arg, None)
440
441 @_ffi.def_extern()
442 def _nameinfo_cb(arg, status, timeouts, node, service):
443+ # Get callback data without removing the reference yet
444+ if _ffi is None or arg not in _handle_to_channel:
445+ return
446+
447 callback = _ffi.from_handle(arg)
448- _global_set.discard(arg)
449
450 if status != _lib.ARES_SUCCESS:
451 result = None
452@@ -112,11 +127,15 @@ def _nameinfo_cb(arg, status, timeouts, node, service):
453 status = None
454
455 callback(result, status)
456+ _handle_to_channel.pop(arg, None)
457
458 @_ffi.def_extern()
459 def _query_cb(arg, status, timeouts, abuf, alen):
460+ # Get callback data without removing the reference yet
461+ if _ffi is None or arg not in _handle_to_channel:
462+ return
463+
464 callback, query_type = _ffi.from_handle(arg)
465- _global_set.discard(arg)
466
467 if status == _lib.ARES_SUCCESS:
468 if query_type == _lib.T_ANY:
469@@ -139,11 +158,15 @@ def _query_cb(arg, status, timeouts, abuf, alen):
470 result = None
471
472 callback(result, status)
473+ _handle_to_channel.pop(arg, None)
474
475 @_ffi.def_extern()
476 def _addrinfo_cb(arg, status, timeouts, res):
477+ # Get callback data without removing the reference yet
478+ if _ffi is None or arg not in _handle_to_channel:
479+ return
480+
481 callback = _ffi.from_handle(arg)
482- _global_set.discard(arg)
483
484 if status != _lib.ARES_SUCCESS:
485 result = None
486@@ -152,6 +175,7 @@ def _addrinfo_cb(arg, status, timeouts, res):
487 status = None
488
489 callback(result, status)
490+ _handle_to_channel.pop(arg, None)
491
492 def parse_result(query_type, abuf, alen):
493 if query_type == _lib.T_A:
494@@ -312,6 +336,53 @@ def parse_result(query_type, abuf, alen):
495 return result, status
496
497
498+class _ChannelShutdownManager:
499+ """Manages channel destruction in a single background thread using SimpleQueue."""
500+
501+ def __init__(self) -> None:
502+ self._queue: SimpleQueue = SimpleQueue()
503+ self._thread: Optional[threading.Thread] = None
504+ self._thread_started = False
505+
506+ def _run_safe_shutdown_loop(self) -> None:
507+ """Process channel destruction requests from the queue."""
508+ while True:
509+ # Block forever until we get a channel to destroy
510+ channel = self._queue.get()
511+
512+ # Sleep for 1 second to ensure c-ares has finished processing
513+ # Its important that c-ares is past this critcial section
514+ # so we use a delay to ensure it has time to finish processing
515+ # https://github.com/c-ares/c-ares/blob/4f42928848e8b73d322b15ecbe3e8d753bf8734e/src/lib/ares_process.c#L1422
516+ time.sleep(1.0)
517+
518+ # Destroy the channel
519+ if _lib is not None and channel is not None:
520+ _lib.ares_destroy(channel[0])
521+
522+ def destroy_channel(self, channel) -> None:
523+ """
524+ Schedule channel destruction on the background thread with a safety delay.
525+
526+ Thread Safety and Synchronization:
527+ This method uses SimpleQueue which is thread-safe for putting items
528+ from multiple threads. The background thread processes channels
529+ sequentially with a 1-second delay before each destruction.
530+ """
531+ # Put the channel in the queue
532+ self._queue.put(channel)
533+
534+ # Start the background thread if not already started
535+ if not self._thread_started:
536+ self._thread_started = True
537+ self._thread = threading.Thread(target=self._run_safe_shutdown_loop, daemon=True)
538+ self._thread.start()
539+
540+
541+# Global shutdown manager instance
542+_shutdown_manager = _ChannelShutdownManager()
543+
544+
545 class Channel:
546 __qtypes__ = (_lib.T_A, _lib.T_AAAA, _lib.T_ANY, _lib.T_CAA, _lib.T_CNAME, _lib.T_MX, _lib.T_NAPTR, _lib.T_NS, _lib.T_PTR, _lib.T_SOA, _lib.T_SRV, _lib.T_TXT)
547 __qclasses__ = (_lib.C_IN, _lib.C_CHAOS, _lib.C_HS, _lib.C_NONE, _lib.C_ANY)
548@@ -334,6 +405,9 @@ class Channel:
549 local_dev: Optional[str] = None,
550 resolvconf_path: Union[str, bytes, None] = None):
551
552+ # Initialize _channel to None first to ensure __del__ doesn't fail
553+ self._channel = None
554+
555 channel = _ffi.new("ares_channel *")
556 options = _ffi.new("struct ares_options *")
557 optmask = 0
558@@ -408,8 +482,9 @@ class Channel:
559 if r != _lib.ARES_SUCCESS:
560 raise AresError('Failed to initialize c-ares channel')
561
562- self._channel = _ffi.gc(channel, lambda x: _lib.ares_destroy(x[0]))
563-
564+ # Initialize all attributes for consistency
565+ self._event_thread = event_thread
566+ self._channel = channel
567 if servers:
568 self.servers = servers
569
570@@ -419,6 +494,46 @@ class Channel:
571 if local_dev:
572 self.set_local_dev(local_dev)
573
574+ def __enter__(self):
575+ """Enter the context manager."""
576+ return self
577+
578+ def __exit__(self, exc_type, exc_val, exc_tb):
579+ """Exit the context manager and close the channel."""
580+ self.close()
581+ return False
582+
583+ def __del__(self) -> None:
584+ """Ensure the channel is destroyed when the object is deleted."""
585+ if self._channel is not None:
586+ # Schedule channel destruction using the global shutdown manager
587+ self._schedule_destruction()
588+
589+ def _create_callback_handle(self, callback_data):
590+ """
591+ Create a callback handle and register it for tracking.
592+
593+ This ensures that:
594+ 1. The callback data is wrapped in a CFFI handle
595+ 2. The handle is mapped to this channel to keep it alive
596+
597+ Args:
598+ callback_data: The data to pass to the callback (usually a callable or tuple)
599+
600+ Returns:
601+ The CFFI handle that can be passed to C functions
602+
603+ Raises:
604+ RuntimeError: If the channel is destroyed
605+
606+ """
607+ if self._channel is None:
608+ raise RuntimeError("Channel is destroyed, no new queries allowed")
609+
610+ userdata = _ffi.new_handle(callback_data)
611+ _handle_to_channel[userdata] = self
612+ return userdata
613+
614 def cancel(self) -> None:
615 _lib.ares_cancel(self._channel[0])
616
617@@ -513,16 +628,14 @@ class Channel:
618 else:
619 raise ValueError("invalid IP address")
620
621- userdata = _ffi.new_handle(callback)
622- _global_set.add(userdata)
623+ userdata = self._create_callback_handle(callback)
624 _lib.ares_gethostbyaddr(self._channel[0], address, _ffi.sizeof(address[0]), family, _lib._host_cb, userdata)
625
626 def gethostbyname(self, name: str, family: socket.AddressFamily, callback: Callable[[Any, int], None]) -> None:
627 if not callable(callback):
628 raise TypeError("a callable is required")
629
630- userdata = _ffi.new_handle(callback)
631- _global_set.add(userdata)
632+ userdata = self._create_callback_handle(callback)
633 _lib.ares_gethostbyname(self._channel[0], parse_name(name), family, _lib._host_cb, userdata)
634
635 def getaddrinfo(
636@@ -545,8 +658,7 @@ class Channel:
637 else:
638 service = ascii_bytes(port)
639
640- userdata = _ffi.new_handle(callback)
641- _global_set.add(userdata)
642+ userdata = self._create_callback_handle(callback)
643
644 hints = _ffi.new('struct ares_addrinfo_hints*')
645 hints.ai_flags = flags
646@@ -574,8 +686,7 @@ class Channel:
647 if query_class not in self.__qclasses__:
648 raise ValueError('invalid query class specified')
649
650- userdata = _ffi.new_handle((callback, query_type))
651- _global_set.add(userdata)
652+ userdata = self._create_callback_handle((callback, query_type))
653 func(self._channel[0], parse_name(name), query_class, query_type, _lib._query_cb, userdata)
654
655 def set_local_ip(self, ip):
656@@ -613,13 +724,47 @@ class Channel:
657 else:
658 raise ValueError("Invalid address argument")
659
660- userdata = _ffi.new_handle(callback)
661- _global_set.add(userdata)
662+ userdata = self._create_callback_handle(callback)
663 _lib.ares_getnameinfo(self._channel[0], _ffi.cast("struct sockaddr*", sa), _ffi.sizeof(sa[0]), flags, _lib._nameinfo_cb, userdata)
664
665 def set_local_dev(self, dev):
666 _lib.ares_set_local_dev(self._channel[0], dev)
667
668+ def close(self) -> None:
669+ """
670+ Close the channel as soon as it's safe to do so.
671+
672+ This method can be called from any thread. The channel will be destroyed
673+ safely using a background thread with a 1-second delay to ensure c-ares
674+ has completed its cleanup.
675+
676+ Note: Once close() is called, no new queries can be started. Any pending
677+ queries will be cancelled and their callbacks will receive ARES_ECANCELLED.
678+
679+ """
680+ if self._channel is None:
681+ # Already destroyed
682+ return
683+
684+ # Cancel all pending queries - this will trigger callbacks with ARES_ECANCELLED
685+ self.cancel()
686+
687+ # Schedule channel destruction
688+ self._schedule_destruction()
689+
690+ def _schedule_destruction(self) -> None:
691+ """Schedule channel destruction using the global shutdown manager."""
692+ if self._channel is None:
693+ return
694+ channel = self._channel
695+ self._channel = None
696+ # Can't start threads during interpreter shutdown
697+ # The channel will be cleaned up by the OS
698+ # TODO: Change to PythonFinalizationError when Python 3.12 support is dropped
699+ with suppress(RuntimeError):
700+ _shutdown_manager.destroy_channel(channel)
701+
702+
703
704 class AresResult:
705 __slots__ = ()
706diff --git a/tests/shutdown_at_exit_script.py b/tests/shutdown_at_exit_script.py
707new file mode 100644
708index 0000000..4bab53c
709--- /dev/null
710+++ b/tests/shutdown_at_exit_script.py
711@@ -0,0 +1,18 @@
712+#!/usr/bin/env python3
713+"""Script to test that shutdown thread handles interpreter shutdown gracefully."""
714+
715+import pycares
716+import sys
717+
718+# Create a channel
719+channel = pycares.Channel()
720+
721+# Start a query to ensure pending handles
722+def callback(result, error):
723+ pass
724+
725+channel.query('example.com', pycares.QUERY_TYPE_A, callback)
726+
727+# Exit immediately - the channel will be garbage collected during interpreter shutdown
728+# This should not raise PythonFinalizationError
729+sys.exit(0)
730\ No newline at end of file
731--
7322.34.1
733
diff --git a/meta-python/recipes-devtools/python/python3-pycares_4.6.0.bb b/meta-python/recipes-devtools/python/python3-pycares_4.6.0.bb
index aa87112c88..90e52a4dec 100644
--- a/meta-python/recipes-devtools/python/python3-pycares_4.6.0.bb
+++ b/meta-python/recipes-devtools/python/python3-pycares_4.6.0.bb
@@ -6,6 +6,7 @@ HOMEPAGE = "https://github.com/saghul/pycares"
6LICENSE = "MIT" 6LICENSE = "MIT"
7LIC_FILES_CHKSUM = "file://LICENSE;md5=b1538fcaea82ebf2313ed648b96c69b1" 7LIC_FILES_CHKSUM = "file://LICENSE;md5=b1538fcaea82ebf2313ed648b96c69b1"
8 8
9SRC_URI += "file://CVE-2025-48945.patch"
9SRC_URI[sha256sum] = "b8a004b18a7465ac9400216bc3fad9d9966007af1ee32f4412d2b3a94e33456e" 10SRC_URI[sha256sum] = "b8a004b18a7465ac9400216bc3fad9d9966007af1ee32f4412d2b3a94e33456e"
10 11
11PYPI_PACKAGE = "pycares" 12PYPI_PACKAGE = "pycares"