diff --git a/common/transport/DcgmIpc.cpp b/common/transport/DcgmIpc.cpp index 86e02afc..4f50f7bc 100644 --- a/common/transport/DcgmIpc.cpp +++ b/common/transport/DcgmIpc.cpp @@ -770,19 +770,27 @@ void DcgmIpc::ConnectTcpAsyncImpl(DcgmIpcConnectTcp &tcpConnect) bufferevent_setcb(bev, DcgmIpc::StaticReadCB, NULL, DcgmIpc::StaticEventCB, this); bufferevent_enable(bev, EV_READ | EV_WRITE); - /* Allow IPv6 IPs to override family */ - sa_family_t family = AF_INET; + /* Determine address family: + * - Bracketed or literal IPv6 address -> AF_INET6 + * - Literal IPv4 address -> AF_INET + * - Hostname -> AF_UNSPEC so libevent resolves it to whichever family DNS returns */ + sa_family_t family = AF_UNSPEC; std::string &hostname = tcpConnect.m_hostname; + char buf[sizeof(struct in6_addr)]; + memset(buf, 0, sizeof(buf)); if (hostname.size() >= 3 && hostname[0] == '[' && hostname[hostname.size() - 1] == ']') { hostname = hostname.substr(1, hostname.size() - 2); + family = AF_INET6; } - char buf[16]; - memset(buf, 0, sizeof(buf)); - if (inet_pton(AF_INET6, hostname.c_str(), buf) > 0) + else if (inet_pton(AF_INET6, hostname.c_str(), buf) > 0) { family = AF_INET6; } + else if (inet_pton(AF_INET, hostname.c_str(), buf) > 0) + { + family = AF_INET; + } int ret = bufferevent_socket_connect_hostname(bev, m_dnsBase, family, hostname.c_str(), tcpConnect.m_port); if (0 != ret) diff --git a/testing/python3/DcgmHandle.py b/testing/python3/DcgmHandle.py index cdd89504..309bcdc1 100644 --- a/testing/python3/DcgmHandle.py +++ b/testing/python3/DcgmHandle.py @@ -16,6 +16,7 @@ import dcgm_structs import dcgm_agent import threading +import ipaddress class DcgmHandle: @@ -98,6 +99,13 @@ def __init__(self, connectParams.persistAfterDisconnect = 0 if ipAddress is not None: + # Wrap bare IPv6 literals in brackets so ParseEndpoint can distinguish + # the address colons from the host:port separator (e.g. "::1" -> "[::1]"). + try: + if ipaddress.ip_address(ipAddress).version == 6: + ipAddress = "[" + ipAddress + "]" + except ValueError: + pass # hostname or already-bracketed address — leave as-is connectToAddress = "tcp://" + ipAddress else: connectToAddress = "unix://" + unixSocketPath diff --git a/testing/python3/tests/test_connection.py b/testing/python3/tests/test_connection.py index 1afe409b..151cd9ac 100644 --- a/testing/python3/tests/test_connection.py +++ b/testing/python3/tests/test_connection.py @@ -45,7 +45,7 @@ def test_connection_disconnect_error_after_shutdown(): @test_utils.run_with_standalone_host_engine(passAppAsArg=True) def test_dcgm_standalone_connection_disconnect_error_after_hostengine_terminate(handle, hostengineApp): ''' - Test that DCGM_ST_CONNECTION_NOT_VALID is returned when the dcgm API is used after + Test that DCGM_ST_CONNECTION_NOT_VALID is returned when the dcgm API is used after the hostengine process is terminated via `nv-hostengine --term`. ''' @@ -70,7 +70,7 @@ def test_dcgm_standalone_connection_disconnect_error_after_hostengine_terminate( @test_utils.run_with_standalone_host_engine(passAppAsArg=True) def test_dcgm_standalone_connection_disconnect_error_after_hostengine_murder(handle, hostengineApp): ''' - Test that DCGM_ST_CONNECTION_NOT_VALID is returned when the dcgm API is used after + Test that DCGM_ST_CONNECTION_NOT_VALID is returned when the dcgm API is used after the hostengine process is killed via a `SIGKILL` signal. ''' handle = pydcgm.DcgmHandle(handle) @@ -129,6 +129,51 @@ def test_dcgm_ipv6_loopback(): nvHe.validate() +@test_utils.run_only_as_root() +@test_utils.run_with_ipv6_enabled() +def test_dcgm_ipv6_hostname_resolution(): + ''' + Verify that a hostname which resolves exclusively to an IPv6 address can connect to a + host engine bound to [::1]. This exercises the AF_UNSPEC path in ConnectTcpAsyncImpl — + the fix for the bug where AF_INET was hardcoded, preventing hostname→IPv6 resolution. + The test is skipped when no IPv6-only hostname alias is available on the system. + ''' + import socket as _socket + + def _resolves_only_to_ipv6(hostname): + try: + if _socket.getaddrinfo(hostname, None, _socket.AF_INET): + return False + except _socket.gaierror: + pass + try: + return bool(_socket.getaddrinfo(hostname, None, _socket.AF_INET6)) + except _socket.gaierror: + return False + + # ip6-loopback / ip6-localhost are standard aliases for ::1 on many Linux distros + test_hostname = next( + (h for h in ('ip6-loopback', 'ip6-localhost') if _resolves_only_to_ipv6(h)), + None, + ) + if test_hostname is None: + test_utils.skip_test("No hostname found that resolves exclusively to an IPv6 address") + + nvHe = apps.NvHostEngineApp(['-b', '[::1]']) + nvHe.start(timeout=90) + + handle = pydcgm.DcgmHandle(ipAddress=test_hostname, timeoutMs=90) + assert handle + dcgmSystem = handle.GetSystem() + dcgmSystem.discovery.GetAllGpuIds() + + del handle + handle = None + + nvHe.terminate() + nvHe.validate() + + @test_utils.run_with_standalone_host_engine(20) @test_utils.run_only_with_live_gpus() def test_dcgm_connection_client_cleanup(handle, gpuIds):