File CVE-2025-48945.patch of Package python3-pycares.19023

From e3b4d40f980def5553f12647ee544c91ef6fc9e5 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston" <nick@koston.org>
Date: Thu, 12 Jun 2025 08:57:19 -0500
Subject: [PATCH] Fix shutdown race

---
 docs/channel.rst                       |  58 ++++-
 examples/cares-asyncio-event-thread.py |  87 +++++++
 examples/cares-asyncio.py              |  34 ++-
 examples/cares-context-manager.py      |  80 ++++++
 examples/cares-poll.py                 |  20 +-
 examples/cares-resolver.py             |  19 +-
 examples/cares-select.py               |  11 +-
 examples/cares-selectors.py            |  23 +-
 src/pycares/__init__.py                | 185 ++++++++++++--
 tests/shutdown_at_exit_script.py       |  18 ++
 tests/test_all.py                      | 335 +++++++++++++++++++++++++
 11 files changed, 822 insertions(+), 48 deletions(-)
 create mode 100644 examples/cares-asyncio-event-thread.py
 create mode 100644 examples/cares-context-manager.py
 create mode 100644 tests/shutdown_at_exit_script.py

Index: pycares-3.1.1/docs/channel.rst
===================================================================
--- pycares-3.1.1.orig/docs/channel.rst
+++ pycares-3.1.1/docs/channel.rst
@@ -55,6 +55,35 @@
 
     The c-ares ``Channel`` provides asynchronous DNS operations.
 
+    The Channel object is designed to handle an unlimited number of DNS queries efficiently.
+    Creating and destroying resolver instances repeatedly is resource-intensive and not
+    recommended. Instead, create a single resolver instance and reuse it throughout your
+    application's lifetime.
+
+    .. important::
+        It is recommended to explicitly close channels when done for predictable resource
+        cleanup. Use :py:meth:`close` which can be called from any thread.
+        While channels will attempt automatic cleanup during garbage collection, explicit
+        closing is safer as it gives you control over when resources are released.
+
+    .. warning::
+        The channel destruction mechanism has a limited throughput of 60 channels per minute
+        (one channel per second) to ensure thread safety and prevent use-after-free errors
+        in c-ares. This means:
+
+        - Avoid creating transient channels for individual queries
+        - Reuse channel instances whenever possible
+        - For applications with high query volume, use a single long-lived channel
+        - If you must create multiple channels, consider pooling them
+
+        Creating and destroying channels rapidly will result in a backlog as the destruction
+        queue processes channels sequentially with a 1-second delay between each.
+
+    The Channel class supports the context manager protocol for automatic cleanup::
+
+        with pycares.Channel() as channel:
+            channel.query('example.com', pycares.QUERY_TYPE_A, callback)
+        # Channel is automatically closed when exiting the context
 
     .. py:method:: gethostbyname(name, family, callback)
 
@@ -205,6 +234,30 @@
 
         Cancel any pending query on this channel. All pending callbacks will be called with ARES_ECANCELLED errorno.
 
+    .. py:method:: close()
+
+        Close the channel as soon as it's safe to do so.
+
+        This method can be called from any thread. The channel will be destroyed
+        safely using a background thread with a 1-second delay to ensure c-ares
+        has completed its cleanup.
+
+        Once close() is called, no new queries can be started. Any pending
+        queries will be cancelled and their callbacks will receive ARES_ECANCELLED.
+
+        .. note::
+            It is recommended to explicitly call :py:meth:`close` rather than
+            relying on garbage collection. Explicit closing provides:
+
+            - Control over when resources are released
+            - Predictable shutdown timing
+            - Proper cleanup of all resources
+
+            While the channel will attempt cleanup during garbage collection,
+            explicit closing is safer and more predictable.
+
+        .. versionadded:: 4.9.0
+
     .. py:method:: process_fd(read_fd, write_fd)
 
         :param int read_fd: File descriptor ready to read from.
@@ -240,4 +293,3 @@
     .. py:attribute:: servers
 
         List of nameservers to use for DNS queries.
-
Index: pycares-3.1.1/examples/cares-asyncio-event-thread.py
===================================================================
--- /dev/null
+++ pycares-3.1.1/examples/cares-asyncio-event-thread.py
@@ -0,0 +1,87 @@
+import asyncio
+import socket
+from typing import Any, Callable, Optional
+
+import pycares
+
+
+class DNSResolver:
+    def __init__(self, loop: Optional[asyncio.AbstractEventLoop] = None) -> None:
+        # Use event_thread=True for automatic event handling in a separate thread
+        self._channel = pycares.Channel(event_thread=True)
+        self.loop = loop or asyncio.get_running_loop()
+
+    def query(
+        self, name: str, query_type: int, cb: Callable[[Any, Optional[int]], None]
+    ) -> None:
+        self._channel.query(name, query_type, cb)
+
+    def gethostbyname(
+        self, name: str, cb: Callable[[Any, Optional[int]], None]
+    ) -> None:
+        self._channel.gethostbyname(name, socket.AF_INET, cb)
+
+    def close(self) -> None:
+        """Thread-safe shutdown of the channel."""
+        # Simply call close() - it's thread-safe and handles everything
+        self._channel.close()
+
+
+async def main() -> None:
+    # Track queries
+    query_count = 0
+    completed_count = 0
+    cancelled_count = 0
+
+    def cb(query_name: str) -> Callable[[Any, Optional[int]], None]:
+        def _cb(result: Any, error: Optional[int]) -> None:
+            nonlocal completed_count, cancelled_count
+            if error == pycares.errno.ARES_ECANCELLED:
+                cancelled_count += 1
+                print(f"Query for {query_name} was CANCELLED")
+            else:
+                completed_count += 1
+                print(
+                    f"Query for {query_name} completed - Result: {result}, Error: {error}"
+                )
+
+        return _cb
+
+    loop = asyncio.get_running_loop()
+    resolver = DNSResolver(loop)
+
+    print("=== Starting first batch of queries ===")
+    # First batch - these should complete
+    resolver.query("google.com", pycares.QUERY_TYPE_A, cb("google.com"))
+    resolver.query("cloudflare.com", pycares.QUERY_TYPE_A, cb("cloudflare.com"))
+    query_count += 2
+
+    # Give them a moment to complete
+    await asyncio.sleep(0.5)
+
+    print("\n=== Starting second batch of queries (will be cancelled) ===")
+    # Second batch - these will be cancelled
+    resolver.query("github.com", pycares.QUERY_TYPE_A, cb("github.com"))
+    resolver.query("stackoverflow.com", pycares.QUERY_TYPE_A, cb("stackoverflow.com"))
+    resolver.gethostbyname("python.org", cb("python.org"))
+    query_count += 3
+
+    # Immediately close - this will cancel pending queries
+    print("\n=== Closing resolver (cancelling pending queries) ===")
+    resolver.close()
+    print("Resolver closed successfully")
+
+    print(f"\n=== Summary ===")
+    print(f"Total queries: {query_count}")
+    print(f"Completed: {completed_count}")
+    print(f"Cancelled: {cancelled_count}")
+
+
+if __name__ == "__main__":
+    # Check if c-ares supports threads
+    if pycares.ares_threadsafety():
+        # For Python 3.7+
+        asyncio.run(main())
+    else:
+        print("c-ares was not compiled with thread support")
+        print("Please see examples/cares-asyncio.py for sock_state_cb usage")
Index: pycares-3.1.1/examples/cares-asyncio.py
===================================================================
--- pycares-3.1.1.orig/examples/cares-asyncio.py
+++ pycares-3.1.1/examples/cares-asyncio.py
@@ -52,18 +52,38 @@ class DNSResolver(object):
     def gethostbyname(self, name, cb):
         self._channel.gethostbyname(name, socket.AF_INET, cb)
 
+    def close(self):
+        """Close the resolver and cleanup resources."""
+        if self._timer:
+            self._timer.cancel()
+            self._timer = None
+        for fd in self._fds:
+            self.loop.remove_reader(fd)
+            self.loop.remove_writer(fd)
+        self._fds.clear()
+        # Note: The channel will be destroyed safely in a background thread
+        # with a 1-second delay to ensure c-ares has completed its cleanup.
+        self._channel.close()
 
-def main():
+
+async def main():
     def cb(result, error):
         print("Result: {}, Error: {}".format(result, error))
-    loop = asyncio.get_event_loop()
+
+    loop = asyncio.get_running_loop()
     resolver = DNSResolver(loop)
-    resolver.query('google.com', pycares.QUERY_TYPE_A, cb)
-    resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
-    resolver.gethostbyname('apple.com', cb)
-    loop.run_forever()
+
+    try:
+        resolver.query('google.com', pycares.QUERY_TYPE_A, cb)
+        resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
+        resolver.gethostbyname('apple.com', cb)
+
+        # Give some time for queries to complete
+        await asyncio.sleep(2)
+    finally:
+        resolver.close()
 
 
 if __name__ == '__main__':
-    main()
+    asyncio.run(main())
 
Index: pycares-3.1.1/examples/cares-context-manager.py
===================================================================
--- /dev/null
+++ pycares-3.1.1/examples/cares-context-manager.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python
+"""
+Example of using pycares Channel as a context manager with event_thread=True.
+
+This demonstrates the simplest way to use pycares with automatic cleanup.
+The event thread handles all socket operations internally, and the context
+manager ensures the channel is properly closed when done.
+"""
+
+import pycares
+import socket
+import time
+
+
+def main():
+    """Run DNS queries using Channel as a context manager."""
+    results = []
+
+    def callback(result, error):
+        """Store results from DNS queries."""
+        if error:
+            print(f"Error {error}: {pycares.errno.strerror(error)}")
+        else:
+            print(f"Result: {result}")
+        results.append((result, error))
+
+    # Use Channel as a context manager with event_thread=True
+    # This is the recommended pattern for simple use cases
+    with pycares.Channel(
+        servers=["8.8.8.8", "8.8.4.4"], timeout=5.0, tries=3, event_thread=True
+    ) as channel:
+        print("=== Making DNS queries ===")
+
+        # Query for A records
+        channel.query("google.com", pycares.QUERY_TYPE_A, callback)
+        channel.query("cloudflare.com", pycares.QUERY_TYPE_A, callback)
+
+        # Query for AAAA records
+        channel.query("google.com", pycares.QUERY_TYPE_AAAA, callback)
+
+        # Query for MX records
+        channel.query("python.org", pycares.QUERY_TYPE_MX, callback)
+
+        # Query for TXT records
+        channel.query("google.com", pycares.QUERY_TYPE_TXT, callback)
+
+        # Query using gethostbyname
+        channel.gethostbyname("github.com", socket.AF_INET, callback)
+
+        # Query using gethostbyaddr
+        channel.gethostbyaddr("8.8.8.8", callback)
+
+        print("\nWaiting for queries to complete...")
+        # Give queries time to complete
+        # In a real application, you would coordinate with your event loop
+        time.sleep(2)
+
+    # Channel is automatically closed when exiting the context
+    print("\n=== Channel closed automatically ===")
+
+    print(f"\nCompleted {len(results)} queries")
+
+    # Demonstrate that the channel is closed and can't be used
+    try:
+        channel.query("example.com", pycares.QUERY_TYPE_A, callback)
+    except RuntimeError as e:
+        print(f"\nExpected error when using closed channel: {e}")
+
+
+if __name__ == "__main__":
+    # Check if c-ares supports threads
+    if pycares.ares_threadsafety():
+        print(f"Using pycares {pycares.__version__} with c-ares {pycares.ARES_VERSION}")
+        print(
+            f"Thread safety: {'enabled' if pycares.ares_threadsafety() else 'disabled'}\n"
+        )
+        main()
+    else:
+        print("This example requires c-ares to be compiled with thread support")
+        print("Use cares-select.py or cares-asyncio.py instead")
Index: pycares-3.1.1/examples/cares-poll.py
===================================================================
--- pycares-3.1.1.orig/examples/cares-poll.py
+++ pycares-3.1.1/examples/cares-poll.py
@@ -48,6 +48,13 @@ class DNSResolver(object):
     def gethostbyname(self, name, cb):
         self._channel.gethostbyname(name, socket.AF_INET, cb)
 
+    def close(self):
+        """Close the resolver and cleanup resources."""
+        for fd in list(self._fd_map):
+            self.poll.unregister(fd)
+        self._fd_map.clear()
+        self._channel.close()
+
 
 if __name__ == '__main__':
     def query_cb(result, error):
@@ -57,8 +64,11 @@ if __name__ == '__main__':
         print(result)
         print(error)
     resolver = DNSResolver()
-    resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
-    resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
-    resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
-    resolver.gethostbyname('apple.com', gethostbyname_cb)
-    resolver.wait_channel()
+    try:
+        resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
+        resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
+        resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
+        resolver.gethostbyname('apple.com', gethostbyname_cb)
+        resolver.wait_channel()
+    finally:
+        resolver.close()
Index: pycares-3.1.1/examples/cares-resolver.py
===================================================================
--- pycares-3.1.1.orig/examples/cares-resolver.py
+++ pycares-3.1.1/examples/cares-resolver.py
@@ -52,6 +52,14 @@ class DNSResolver(object):
     def gethostbyname(self, name, cb):
         self._channel.gethostbyname(name, socket.AF_INET, cb)
 
+    def close(self):
+        """Close the resolver and cleanup resources."""
+        self._timer.stop()
+        for handle in self._fd_map.values():
+            handle.close()
+        self._fd_map.clear()
+        self._channel.close()
+
 
 if __name__ == '__main__':
     def query_cb(result, error):
@@ -62,8 +70,11 @@ if __name__ == '__main__':
         print(error)
     loop = pyuv.Loop.default_loop()
     resolver = DNSResolver(loop)
-    resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
-    resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
-    resolver.gethostbyname('apple.com', gethostbyname_cb)
-    loop.run()
+    try:
+        resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
+        resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
+        resolver.gethostbyname('apple.com', gethostbyname_cb)
+        loop.run()
+    finally:
+        resolver.close()
 
Index: pycares-3.1.1/examples/cares-select.py
===================================================================
--- pycares-3.1.1.orig/examples/cares-select.py
+++ pycares-3.1.1/examples/cares-select.py
@@ -25,9 +25,12 @@ if __name__ == '__main__':
         print(result)
         print(error)
     channel = pycares.Channel()
-    channel.gethostbyname('google.com', socket.AF_INET, cb)
-    channel.query('google.com', pycares.QUERY_TYPE_A, cb)
-    channel.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
-    wait_channel(channel)
+    try:
+        channel.gethostbyname('google.com', socket.AF_INET, cb)
+        channel.query('google.com', pycares.QUERY_TYPE_A, cb)
+        channel.query('sip2sip.info', pycares.QUERY_TYPE_SOA, cb)
+        wait_channel(channel)
+    finally:
+        channel.close()
     print('Done!')
 
Index: pycares-3.1.1/examples/cares-selectors.py
===================================================================
--- pycares-3.1.1.orig/examples/cares-selectors.py
+++ pycares-3.1.1/examples/cares-selectors.py
@@ -47,6 +47,14 @@ class DNSResolver(object):
     def gethostbyname(self, name, cb):
         self._channel.gethostbyname(name, socket.AF_INET, cb)
 
+    def close(self):
+        """Close the resolver and cleanup resources."""
+        for fd in list(self._fd_map):
+            self.poll.unregister(fd)
+        self._fd_map.clear()
+        self.poll.close()
+        self._channel.close()
+
 
 if __name__ == '__main__':
     def query_cb(result, error):
@@ -56,10 +64,13 @@ if __name__ == '__main__':
         print(result)
         print(error)
     resolver = DNSResolver()
-    resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
-    resolver.query('google.com', pycares.QUERY_TYPE_AAAA, query_cb)
-    resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
-    resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
-    resolver.gethostbyname('apple.com', gethostbyname_cb)
-    resolver.wait_channel()
+    try:
+        resolver.query('google.com', pycares.QUERY_TYPE_A, query_cb)
+        resolver.query('google.com', pycares.QUERY_TYPE_AAAA, query_cb)
+        resolver.query('facebook.com', pycares.QUERY_TYPE_A, query_cb)
+        resolver.query('sip2sip.info', pycares.QUERY_TYPE_SOA, query_cb)
+        resolver.gethostbyname('apple.com', gethostbyname_cb)
+        resolver.wait_channel()
+    finally:
+        resolver.close()
 
Index: pycares-3.1.1/src/pycares/__init__.py
===================================================================
--- pycares-3.1.1.orig/src/pycares/__init__.py
+++ pycares-3.1.1/src/pycares/__init__.py
@@ -12,9 +12,13 @@ from ._version import __version__
 import collections.abc
 import socket
 import math
-import functools
-import sys
-
+import threading
+import time
+import weakref
+
+from contextlib import suppress
+from typing import Any, Callable, Optional, Dict, Union
+from queue import SimpleQueue
 
 exported_pycares_symbols = [
     # Flag values
@@ -83,17 +87,25 @@ class AresError(Exception):
 
 # callback helpers
 
-_global_set = set()
+_handle_to_channel: Dict[Any, "Channel"] = {}  # Maps handle to channel to prevent use-after-free
+
 
 @_ffi.def_extern()
 def _sock_state_cb(data, socket_fd, readable, writable):
+    # Note: sock_state_cb handle is not tracked in _handle_to_channel
+    # because it has a different lifecycle (tied to the channel, not individual queries)
+    if _ffi is None:
+        return
     sock_state_cb = _ffi.from_handle(data)
     sock_state_cb(socket_fd, readable, writable)
 
 @_ffi.def_extern()
 def _host_cb(arg, status, timeouts, hostent):
+    # Get callback data without removing the reference yet
+    if _ffi is None or arg not in _handle_to_channel:
+        return
+
     callback = _ffi.from_handle(arg)
-    _global_set.discard(arg)
 
     if status != _lib.ARES_SUCCESS:
         result = None
@@ -102,11 +114,15 @@ def _host_cb(arg, status, timeouts, host
         status = None
 
     callback(result, status)
+    _handle_to_channel.pop(arg, None)
 
 @_ffi.def_extern()
 def _nameinfo_cb(arg, status, timeouts, node, service):
+    # Get callback data without removing the reference yet
+    if _ffi is None or arg not in _handle_to_channel:
+        return
+
     callback = _ffi.from_handle(arg)
-    _global_set.discard(arg)
 
     if status != _lib.ARES_SUCCESS:
         result = None
@@ -115,11 +131,15 @@ def _nameinfo_cb(arg, status, timeouts,
         status = None
 
     callback(result, status)
+    _handle_to_channel.pop(arg, None)
 
 @_ffi.def_extern()
 def _query_cb(arg, status, timeouts, abuf, alen):
+    # Get callback data without removing the reference yet
+    if _ffi is None or arg not in _handle_to_channel:
+        return
+
     callback, query_type = _ffi.from_handle(arg)
-    _global_set.discard(arg)
 
     if status == _lib.ARES_SUCCESS:
         if query_type == _lib.T_ANY:
@@ -142,6 +162,7 @@ def _query_cb(arg, status, timeouts, abu
         result = None
 
     callback(result, status)
+    _handle_to_channel.pop(arg, None)
 
 def parse_result(query_type, abuf, alen):
     if query_type == _lib.T_A:
@@ -289,6 +310,53 @@ def parse_result(query_type, abuf, alen)
     return result, status
 
 
+class _ChannelShutdownManager:
+    """Manages channel destruction in a single background thread using SimpleQueue."""
+
+    def __init__(self) -> None:
+        self._queue: SimpleQueue = SimpleQueue()
+        self._thread: Optional[threading.Thread] = None
+        self._thread_started = False
+
+    def _run_safe_shutdown_loop(self) -> None:
+        """Process channel destruction requests from the queue."""
+        while True:
+            # Block forever until we get a channel to destroy
+            channel = self._queue.get()
+
+            # Sleep for 1 second to ensure c-ares has finished processing
+            # Its important that c-ares is past this critcial section
+            # so we use a delay to ensure it has time to finish processing
+            # https://github.com/c-ares/c-ares/blob/4f42928848e8b73d322b15ecbe3e8d753bf8734e/src/lib/ares_process.c#L1422
+            time.sleep(1.0)
+
+            # Destroy the channel
+            if _lib is not None and channel is not None:
+                _lib.ares_destroy(channel[0])
+
+    def destroy_channel(self, channel) -> None:
+        """
+        Schedule channel destruction on the background thread with a safety delay.
+
+        Thread Safety and Synchronization:
+        This method uses SimpleQueue which is thread-safe for putting items
+        from multiple threads. The background thread processes channels
+        sequentially with a 1-second delay before each destruction.
+        """
+        # Put the channel in the queue
+        self._queue.put(channel)
+
+        # Start the background thread if not already started
+        if not self._thread_started:
+            self._thread_started = True
+            self._thread = threading.Thread(target=self._run_safe_shutdown_loop, daemon=True)
+            self._thread.start()
+
+
+# Global shutdown manager instance
+_shutdown_manager = _ChannelShutdownManager()
+
+
 class Channel:
     __qtypes__ = (_lib.T_A, _lib.T_AAAA, _lib.T_ANY, _lib.T_CNAME, _lib.T_MX, _lib.T_NAPTR, _lib.T_NS, _lib.T_PTR, _lib.T_SOA, _lib.T_SRV, _lib.T_TXT)
 
@@ -310,6 +378,9 @@ class Channel:
                  local_dev = None,
                  resolvconf_path = None):
 
+        # Initialize _channel to None first to ensure __del__ doesn't fail
+        self._channel = None
+
         channel = _ffi.new("ares_channel *")
         options = _ffi.new("struct ares_options *")
         optmask = 0
@@ -384,8 +455,9 @@ class Channel:
         if r != _lib.ARES_SUCCESS:
             raise AresError('Failed to initialize c-ares channel')
 
-        self._channel = _ffi.gc(channel, lambda x: _lib.ares_destroy(x[0]))
-
+        # Initialize all attributes for consistency
+        self._event_thread = event_thread
+        self._channel = channel
         if servers:
             self.servers = servers
 
@@ -395,6 +467,46 @@ class Channel:
         if local_dev:
             self.set_local_dev(local_dev)
 
+    def __enter__(self):
+        """Enter the context manager."""
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        """Exit the context manager and close the channel."""
+        self.close()
+        return False
+
+    def __del__(self) -> None:
+        """Ensure the channel is destroyed when the object is deleted."""
+        if self._channel is not None:
+            # Schedule channel destruction using the global shutdown manager
+            self._schedule_destruction()
+
+    def _create_callback_handle(self, callback_data):
+        """
+        Create a callback handle and register it for tracking.
+
+        This ensures that:
+        1. The callback data is wrapped in a CFFI handle
+        2. The handle is mapped to this channel to keep it alive
+
+        Args:
+            callback_data: The data to pass to the callback (usually a callable or tuple)
+
+        Returns:
+            The CFFI handle that can be passed to C functions
+
+        Raises:
+            RuntimeError: If the channel is destroyed
+
+        """
+        if self._channel is None:
+            raise RuntimeError("Channel is destroyed, no new queries allowed")
+
+        userdata = _ffi.new_handle(callback_data)
+        _handle_to_channel[userdata] = self
+        return userdata
+
     def cancel(self):
         _lib.ares_cancel(self._channel[0])
 
@@ -489,16 +601,14 @@ class Channel:
         else:
             raise ValueError("invalid IP address")
 
-        userdata = _ffi.new_handle(callback)
-        _global_set.add(userdata)
+        userdata = self._create_callback_handle(callback)
         _lib.ares_gethostbyaddr(self._channel[0], address, _ffi.sizeof(address[0]), family, _lib._host_cb, userdata)
 
     def gethostbyname(self, name, family, callback):
         if not callable(callback):
             raise TypeError("a callable is required")
 
-        userdata = _ffi.new_handle(callback)
-        _global_set.add(userdata)
+        userdata = self._create_callback_handle(callback)
         _lib.ares_gethostbyname(self._channel[0], parse_name(name), family, _lib._host_cb, userdata)
 
     def query(self, name, query_type, callback):
@@ -514,8 +624,7 @@ class Channel:
         if query_type not in self.__qtypes__:
             raise ValueError('invalid query type specified')
 
-        userdata = _ffi.new_handle((callback, query_type))
-        _global_set.add(userdata)
+        userdata = self._create_callback_handle(callback)
         func(self._channel[0], parse_name(name), _lib.C_IN, query_type, _lib._query_cb, userdata)
 
     def set_local_ip(self, ip):
@@ -551,13 +660,47 @@ class Channel:
         else:
             raise ValueError("invalid IP address")
 
-        userdata = _ffi.new_handle(callback)
-        _global_set.add(userdata)
+        userdata = self._create_callback_handle(callback)
         _lib.ares_getnameinfo(self._channel[0], _ffi.cast("struct sockaddr*", sa), _ffi.sizeof(sa[0]), flags, _lib._nameinfo_cb, userdata)
 
     def set_local_dev(self, dev):
         _lib.ares_set_local_dev(self._channel[0], dev)
 
+    def close(self) -> None:
+        """
+        Close the channel as soon as it's safe to do so.
+
+        This method can be called from any thread. The channel will be destroyed
+        safely using a background thread with a 1-second delay to ensure c-ares
+        has completed its cleanup.
+
+        Note: Once close() is called, no new queries can be started. Any pending
+        queries will be cancelled and their callbacks will receive ARES_ECANCELLED.
+
+        """
+        if self._channel is None:
+            # Already destroyed
+            return
+
+        # Cancel all pending queries - this will trigger callbacks with ARES_ECANCELLED
+        self.cancel()
+
+        # Schedule channel destruction
+        self._schedule_destruction()
+
+    def _schedule_destruction(self) -> None:
+        """Schedule channel destruction using the global shutdown manager."""
+        if self._channel is None:
+            return
+        channel = self._channel
+        self._channel = None
+        # Can't start threads during interpreter shutdown
+        # The channel will be cleaned up by the OS
+        # TODO: Change to PythonFinalizationError when Python 3.12 support is dropped
+        with suppress(RuntimeError):
+            _shutdown_manager.destroy_channel(channel)
+
+
 
 class AresResult:
     __slots__ = ()
Index: pycares-3.1.1/tests/shutdown_at_exit_script.py
===================================================================
--- /dev/null
+++ pycares-3.1.1/tests/shutdown_at_exit_script.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""Script to test that shutdown thread handles interpreter shutdown gracefully."""
+
+import pycares
+import sys
+
+# Create a channel
+channel = pycares.Channel()
+
+# Start a query to ensure pending handles
+def callback(result, error):
+    pass
+
+channel.query('example.com', pycares.QUERY_TYPE_A, callback)
+
+# Exit immediately - the channel will be garbage collected during interpreter shutdown
+# This should not raise PythonFinalizationError
+sys.exit(0)
\ No newline at end of file
Index: pycares-3.1.1/tests/tests.py
===================================================================
--- pycares-3.1.1.orig/tests/tests.py
+++ pycares-3.1.1/tests/tests.py
@@ -1,12 +1,15 @@
 #!/usr/bin/env python
 
+import functools
+import gc
 import ipaddress
 import os
 import select
 import socket
 import sys
 import unittest
-
+import time
+import weakref
 import pycares
 
 FIXTURES_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), 'fixtures'))
@@ -540,6 +543,338 @@ class DNSTest(unittest.TestCase):
             self.assertTrue(type(pycares.errno.strerror(key)), str)
 
 
+class ChannelCloseTest(unittest.TestCase):
+
+    def test_close_from_same_thread(self):
+        # Test that close() works when called from the same thread
+        channel = pycares.Channel()
+
+        # Start a query
+        result = []
+        def cb(res, err):
+            result.append((res, err))
+
+        channel.query('google.com', pycares.QUERY_TYPE_A, cb)
+
+        # Close should work fine from same thread
+        channel.close()
+
+        # Channel should be closed, no more operations allowed
+        with self.assertRaises(Exception):
+            channel.query('google.com', pycares.QUERY_TYPE_A, cb)
+
+    def test_close_from_different_thread_safe(self):
+        # Test that close() can be safely called from different thread
+        channel = pycares.Channel()
+        close_complete = threading.Event()
+
+        def close_in_thread():
+            channel.close()
+            close_complete.set()
+
+        thread = threading.Thread(target=close_in_thread)
+        thread.start()
+        thread.join()
+
+        # Should complete without errors
+        self.assertTrue(close_complete.is_set())
+        # Channel should be destroyed
+        self.assertIsNone(channel._channel)
+
+    def test_close_idempotent(self):
+        # Test that close() can be called multiple times
+        channel = pycares.Channel()
+        channel.close()
+        channel.close()  # Should not raise
+
+    def test_threadsafe_close(self):
+        # Test that close() can be called from any thread
+        channel = pycares.Channel()
+        close_complete = threading.Event()
+
+        # Close from another thread
+        def close_in_thread():
+            channel.close()
+            close_complete.set()
+
+        thread = threading.Thread(target=close_in_thread)
+        thread.start()
+        thread.join()
+
+        self.assertTrue(close_complete.is_set())
+        self.assertIsNone(channel._channel)
+
+    def test_threadsafe_close_with_pending_queries(self):
+        # Test close with queries in flight
+        channel = pycares.Channel()
+        query_completed = threading.Event()
+        cancelled_count = 0
+
+        def cb(result, error):
+            nonlocal cancelled_count
+            if error == pycares.errno.ARES_ECANCELLED:
+                cancelled_count += 1
+            if cancelled_count >= 3:  # All queries cancelled
+                query_completed.set()
+
+        # Start several queries
+        channel.query('google.com', pycares.QUERY_TYPE_A, cb)
+        channel.query('github.com', pycares.QUERY_TYPE_A, cb)
+        channel.query('python.org', pycares.QUERY_TYPE_A, cb)
+
+        # Close immediately - this should cancel pending queries
+        channel.close()
+
+        # Wait for cancellation callbacks
+        self.assertTrue(query_completed.wait(timeout=2.0))
+        self.assertEqual(cancelled_count, 3)  # All 3 queries should be cancelled
+
+    def test_query_after_close_raises(self):
+        # Test that queries raise after close()
+        channel = pycares.Channel()
+        channel.close()
+
+        def cb(result, error):
+            pass
+
+        with self.assertRaises(RuntimeError) as cm:
+            channel.query('example.com', pycares.QUERY_TYPE_A, cb)
+
+        self.assertIn("destroyed", str(cm.exception))
+
+    def test_close_from_different_thread(self):
+        # Test that close works from different thread
+        channel = pycares.Channel()
+        close_complete = threading.Event()
+
+        def close_in_thread():
+            channel.close()
+            close_complete.set()
+
+        thread = threading.Thread(target=close_in_thread)
+        thread.start()
+        thread.join()
+
+        self.assertTrue(close_complete.is_set())
+        self.assertIsNone(channel._channel)
+
+    def test_automatic_cleanup_same_thread(self):
+        # Test that __del__ cleans up automatically when in same thread
+        # Create a channel and weak reference to track its lifecycle
+        channel = pycares.Channel()
+        weak_ref = weakref.ref(channel)
+
+        # Verify channel exists
+        self.assertIsNotNone(weak_ref())
+
+        # Delete the channel reference
+        del channel
+
+        # Force garbage collection
+        gc.collect()
+        gc.collect()  # Sometimes needs multiple passes
+
+        # Channel should be gone now (cleaned up by __del__)
+        self.assertIsNone(weak_ref())
+
+    def test_automatic_cleanup_different_thread_with_shutdown_thread(self):
+        # Test that __del__ now safely cleans up using shutdown thread
+        # when channel is deleted from a different thread
+        channel_container = []
+        weak_ref_container = []
+
+        def create_channel_in_thread():
+            channel = pycares.Channel()
+            weak_ref = weakref.ref(channel)
+            channel_container.append(channel)
+            weak_ref_container.append(weak_ref)
+
+        # Create channel in different thread
+        thread = threading.Thread(target=create_channel_in_thread)
+        thread.start()
+        thread.join()
+
+        # Get the weak reference
+        weak_ref = weak_ref_container[0]
+
+        # Verify channel exists
+        self.assertIsNotNone(weak_ref())
+
+        # Delete the channel reference from main thread
+        channel_container.clear()
+
+        # Force garbage collection
+        gc.collect()
+        gc.collect()
+
+        # Give the shutdown thread time to run
+        time.sleep(0.1)
+
+        # Channel should be cleaned up via the shutdown thread
+        self.assertIsNone(weak_ref())
+
+        # Note: The shutdown thread mechanism ensures safe cleanup
+        # even when deleted from a different thread
+
+    def test_no_crash_on_interpreter_shutdown(self):
+        # Test that channels with pending queries don't crash during interpreter shutdown
+        import subprocess
+
+        # Path to the shutdown test script
+        script_path = os.path.join(os.path.dirname(__file__), 'shutdown_at_exit_script.py')
+
+        # Run the script in a subprocess
+        result = subprocess.run(
+            [sys.executable, script_path],
+            capture_output=True,
+            text=True
+        )
+
+        # Should exit cleanly without errors
+        self.assertEqual(result.returncode, 0)
+        # Should not have PythonFinalizationError in stderr
+        self.assertNotIn('PythonFinalizationError', result.stderr)
+        self.assertNotIn('can\'t create new thread at interpreter shutdown', result.stderr)
+
+    def test_context_manager(self):
+        # Test that Channel works as a context manager
+        result_container = []
+
+        def cb(result, error):
+            result_container.append((result, error))
+
+        # Test normal usage
+        with pycares.Channel() as channel:
+            self.assertIsNotNone(channel._channel)
+            # Can make queries while in context
+            channel.query('example.com', pycares.QUERY_TYPE_A, cb)
+
+        # Channel should be destroyed after exiting context
+        self.assertIsNone(channel._channel)
+
+        # Test with exception
+        try:
+            with pycares.Channel() as channel2:
+                self.assertIsNotNone(channel2._channel)
+                raise ValueError("Test exception")
+        except ValueError:
+            pass
+
+        # Channel should still be destroyed even with exception
+        self.assertIsNone(channel2._channel)
+
+    def test_concurrent_close_multiple_channels(self):
+        # Test multiple channels being closed concurrently
+        channels = []
+        for _ in range(10):
+            channels.append(pycares.Channel())
+
+        close_events = []
+        threads = []
+
+        def close_channel(ch, event):
+            ch.close()
+            event.set()
+
+        # Start threads to close all channels concurrently
+        for ch in channels:
+            event = threading.Event()
+            close_events.append(event)
+            thread = threading.Thread(target=close_channel, args=(ch, event))
+            threads.append(thread)
+            thread.start()
+
+        # Wait for all threads to complete
+        for thread in threads:
+            thread.join()
+
+        # Verify all channels were closed
+        for event in close_events:
+            self.assertTrue(event.is_set())
+
+        for ch in channels:
+            self.assertTrue(ch._channel is None)
+
+    def test_rapid_channel_creation_and_close(self):
+        # Test rapid creation and closing of channels
+        for i in range(20):
+            channel = pycares.Channel()
+
+            # Alternate between same-thread and cross-thread closes
+            if i % 2 == 0:
+                channel.close()
+            else:
+                def close_in_thread(channel):
+                    channel.close()
+
+                thread = threading.Thread(target=functools.partial(close_in_thread, channel))
+                thread.start()
+                thread.join()
+
+            # Verify channel is closed
+            self.assertTrue(channel._channel is None)
+
+    def test_close_with_active_queries_from_different_thread(self):
+        # Test closing a channel with active queries from a different thread
+        channel = pycares.Channel()
+        query_started = threading.Event()
+        query_cancelled = threading.Event()
+
+        def query_cb(result, error):
+            if error == pycares.errno.ARES_ECANCELLED:
+                query_cancelled.set()
+
+        # Start queries in one thread
+        def start_queries():
+            # Use a non-responsive server to ensure queries stay pending
+            channel.servers = ['192.0.2.1']  # TEST-NET-1, should not respond
+            for i in range(5):
+                channel.query(f'test{i}.example.com', pycares.QUERY_TYPE_A, query_cb)
+            query_started.set()
+
+        query_thread = threading.Thread(target=start_queries)
+        query_thread.start()
+
+        # Wait for queries to start
+        self.assertTrue(query_started.wait(timeout=2.0))
+
+        # Close from main thread
+        channel.close()
+
+        # Verify channel is closed
+        self.assertTrue(channel._channel is None)
+
+        query_thread.join()
+
+    def test_multiple_closes_from_different_threads(self):
+        # Test that multiple threads can call close() safely
+        channel = pycares.Channel()
+        close_count = 0
+        close_lock = threading.Lock()
+
+        def close_and_count():
+            channel.close()
+            with close_lock:
+                nonlocal close_count
+                close_count += 1
+
+        # Start multiple threads trying to close the same channel
+        threads = []
+        for _ in range(5):
+            thread = threading.Thread(target=close_and_count)
+            threads.append(thread)
+            thread.start()
+
+        # Wait for all threads
+        for thread in threads:
+            thread.join()
+
+        # All threads should complete successfully
+        self.assertEqual(close_count, 5)
+        self.assertTrue(channel._channel is None)
+
+
+
 if __name__ == '__main__':
     unittest.main(verbosity=2)
 
openSUSE Build Service is sponsored by