Avoid aiodns resolver memory leak (#5941)

* Avoid aiodns resolver memory leak

In certain cases, the aiodns resolver can leak memory. This also
leads to Fatal `Python error… ffi.from_handle()`. This addresses
the issue by ensuring that the resolver is properly closed
when it is no longer needed.

* Address coderabbitai feedback

* Fix pytest

* Fix pytest
This commit is contained in:
Stefan Agner 2025-06-12 11:32:53 +02:00 committed by GitHub
parent d5b5a328d7
commit bdbd09733a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 26 additions and 23 deletions

View File

@ -15,6 +15,24 @@ from ..const import DNS_CHECK_HOST, ContextType, IssueType
from .base import CheckBase from .base import CheckBase
async def check_server(
loop: asyncio.AbstractEventLoop, server: str, qtype: str
) -> None:
"""Check a DNS server and report issues."""
ip_addr = server[6:] if server.startswith("dns://") else server
resolver = DNSResolver(loop=loop, nameservers=[ip_addr])
try:
await resolver.query(DNS_CHECK_HOST, qtype)
finally:
def _delete_resolver():
"""Close resolver to avoid memory leaks."""
nonlocal resolver
del resolver
loop.call_later(1, _delete_resolver)
def setup(coresys: CoreSys) -> CheckBase: def setup(coresys: CoreSys) -> CheckBase:
"""Check setup function.""" """Check setup function."""
return CheckDNSServer(coresys) return CheckDNSServer(coresys)
@ -33,7 +51,7 @@ class CheckDNSServer(CheckBase):
"""Run check if not affected by issue.""" """Run check if not affected by issue."""
dns_servers = self.dns_servers dns_servers = self.dns_servers
results = await asyncio.gather( results = await asyncio.gather(
*[self._check_server(server) for server in dns_servers], *[check_server(self.sys_loop, server, "A") for server in dns_servers],
return_exceptions=True, return_exceptions=True,
) )
for i in (r for r in range(len(results)) if isinstance(results[r], DNSError)): for i in (r for r in range(len(results)) if isinstance(results[r], DNSError)):
@ -51,18 +69,12 @@ class CheckDNSServer(CheckBase):
return False return False
try: try:
await self._check_server(reference) await check_server(self.sys_loop, reference, "A")
except DNSError: except DNSError:
return True return True
return False return False
async def _check_server(self, server: str):
"""Check a DNS server and report issues."""
ip_addr = server[6:] if server.startswith("dns://") else server
resolver = DNSResolver(nameservers=[ip_addr])
await resolver.query(DNS_CHECK_HOST, "A")
@property @property
def dns_servers(self) -> list[str]: def dns_servers(self) -> list[str]:
"""All user and system provided dns servers.""" """All user and system provided dns servers."""

View File

@ -3,15 +3,16 @@
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
from aiodns import DNSResolver
from aiodns.error import DNSError from aiodns.error import DNSError
from supervisor.resolution.checks.dns_server import check_server
from ...const import CoreState from ...const import CoreState
from ...coresys import CoreSys from ...coresys import CoreSys
from ...jobs.const import JobCondition, JobExecutionLimit from ...jobs.const import JobCondition, JobExecutionLimit
from ...jobs.decorator import Job from ...jobs.decorator import Job
from ...utils.sentry import async_capture_exception from ...utils.sentry import async_capture_exception
from ..const import DNS_CHECK_HOST, DNS_ERROR_NO_DATA, ContextType, IssueType from ..const import DNS_ERROR_NO_DATA, ContextType, IssueType
from .base import CheckBase from .base import CheckBase
@ -33,7 +34,7 @@ class CheckDNSServerIPv6(CheckBase):
"""Run check if not affected by issue.""" """Run check if not affected by issue."""
dns_servers = self.dns_servers dns_servers = self.dns_servers
results = await asyncio.gather( results = await asyncio.gather(
*[self._check_server(server) for server in dns_servers], *[check_server(self.sys_loop, server, "AAAA") for server in dns_servers],
return_exceptions=True, return_exceptions=True,
) )
for i in ( for i in (
@ -58,19 +59,13 @@ class CheckDNSServerIPv6(CheckBase):
return False return False
try: try:
await self._check_server(reference) await check_server(self.sys_loop, reference, "AAAA")
except DNSError as dns_error: except DNSError as dns_error:
if dns_error.args[0] != DNS_ERROR_NO_DATA: if dns_error.args[0] != DNS_ERROR_NO_DATA:
return True return True
return False return False
async def _check_server(self, server: str):
"""Check a DNS server and report issues."""
ip_addr = server[6:] if server.startswith("dns://") else server
resolver = DNSResolver(nameservers=[ip_addr])
await resolver.query(DNS_CHECK_HOST, "AAAA")
@property @property
def dns_servers(self) -> list[str]: def dns_servers(self) -> list[str]:
"""All user and system provided dns servers.""" """All user and system provided dns servers."""

View File

@ -21,10 +21,6 @@ def fixture_mock_dns_query():
"supervisor.resolution.checks.dns_server.DNSResolver.query", "supervisor.resolution.checks.dns_server.DNSResolver.query",
new_callable=AsyncMock, new_callable=AsyncMock,
), ),
patch(
"supervisor.resolution.checks.dns_server_ipv6.DNSResolver.query",
new_callable=AsyncMock,
),
): ):
yield yield

View File

@ -15,7 +15,7 @@ from supervisor.resolution.const import ContextType, IssueType
async def fixture_dns_query() -> AsyncMock: async def fixture_dns_query() -> AsyncMock:
"""Mock aiodns query.""" """Mock aiodns query."""
with patch( with patch(
"supervisor.resolution.checks.dns_server_ipv6.DNSResolver.query", "supervisor.resolution.checks.dns_server.DNSResolver.query",
new_callable=AsyncMock, new_callable=AsyncMock,
) as dns_query: ) as dns_query:
yield dns_query yield dns_query