Skip to content

Commit 67c33d4

Browse files
committed
Fix inotify watch exhaustion on large resolver lists
This release fixes a critical issue where processing large lists of DNS resolvers (65k+) would exhaust system inotify watches, causing c-ares initialization failures and resulting in incomplete validation results. Changes: - Implemented DNS resolver instance caching to reuse resolver objects - Added thread-safe resolver cache with async lock protection - Each unique server now uses a single resolver instance instead of creating a new one for every query - Prevents "Failed to initialize c-ares channel" errors - Significantly reduces system resource usage during large batch processing Tested with 65k+ resolver lists - now processes all resolvers successfully without resource exhaustion. Version bumped to 2.3.1
1 parent 241f1a9 commit 67c33d4

3 files changed

Lines changed: 36 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to PyResolvers will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.3.1] - 2025-01-11
9+
10+
### Fixed
11+
- Fixed inotify watch exhaustion when processing large resolver lists (65k+)
12+
- Implemented DNS resolver instance caching to prevent resource exhaustion
13+
- Resolvers are now reused per unique server, reducing system resource usage
14+
- Eliminated "Failed to initialize c-ares channel" errors on large batches
15+
816
## [1.1.0] - 2025-01-11
917

1018
### Added
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = '2.3.0'
1+
__version__ = '2.3.1'
22

pyresolvers/validator.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def __init__(
7777
self.verbose = verbose
7878
self._baseline_ip = ""
7979
self._baseline_data: Dict[str, Dict] = {}
80+
# Resolver cache to prevent creating too many instances
81+
self._resolver_cache: Dict[str, aiodns.DNSResolver] = {}
82+
self._cache_lock: Optional[asyncio.Lock] = None
8083

8184
@staticmethod
8285
def _random_subdomain(length: int = SUBDOMAIN_LENGTH) -> str:
@@ -96,11 +99,31 @@ def _log(self, msg: str) -> None:
9699
if self.verbose:
97100
print(msg)
98101

102+
async def _get_resolver(self, nameserver: str, timeout: Optional[int] = None) -> aiodns.DNSResolver:
103+
"""Get or create a cached DNS resolver for the given nameserver.
104+
105+
This prevents creating too many resolver instances which would exhaust
106+
inotify watches on Linux systems.
107+
"""
108+
# Initialize lock on first use
109+
if self._cache_lock is None:
110+
self._cache_lock = asyncio.Lock()
111+
112+
cache_key = f"{nameserver}:{timeout or self.timeout}"
113+
114+
async with self._cache_lock:
115+
if cache_key not in self._resolver_cache:
116+
self._resolver_cache[cache_key] = aiodns.DNSResolver(
117+
nameservers=[nameserver],
118+
timeout=timeout or self.timeout
119+
)
120+
return self._resolver_cache[cache_key]
121+
99122
async def _setup_baseline_single(self, resolver_ip: str) -> bool:
100123
"""Setup baseline from single trusted resolver."""
101124
self._log(f"[INFO] {resolver_ip} - Establishing baseline")
102125
try:
103-
resolver = aiodns.DNSResolver(nameservers=[resolver_ip], timeout=self.timeout)
126+
resolver = await self._get_resolver(resolver_ip)
104127
data = {}
105128

106129
# Get baseline IP
@@ -192,7 +215,7 @@ def _matches_baseline(self, has_nxdomain: bool) -> bool:
192215
async def _measure_latency(self, server: str) -> float:
193216
"""Measure simple DNS query latency."""
194217
try:
195-
resolver = aiodns.DNSResolver(nameservers=[server], timeout=1)
218+
resolver = await self._get_resolver(server, timeout=1)
196219
start = time.time()
197220
await resolver.query(self.baseline_domain, 'A')
198221
return (time.time() - start) * 1000
@@ -205,7 +228,8 @@ async def _validate_server(self, server: str) -> ValidationResult:
205228
return ValidationResult(server, False, -1, "Invalid IP")
206229

207230
try:
208-
resolver = aiodns.DNSResolver(nameservers=[server], timeout=FAST_TIMEOUT if self.use_fast_timeout else self.timeout)
231+
timeout = FAST_TIMEOUT if self.use_fast_timeout else self.timeout
232+
resolver = await self._get_resolver(server, timeout=timeout)
209233

210234
# Run ALL checks in parallel for max speed
211235
poison_task = self._check_poisoning(resolver, server)

0 commit comments

Comments
 (0)