diff --git a/.github/wordlist.txt b/.github/wordlist.txt index 3ab05e25..058ad828 100644 --- a/.github/wordlist.txt +++ b/.github/wordlist.txt @@ -32,10 +32,12 @@ dataset de deallocates deallocation +DNS ElastiCache extensibility failover FPM +FreeBSD getaddrinfo glibc gmail @@ -56,6 +58,7 @@ libvalkey Libvalkey localhost Lua +macOS michael minimalistic MPTCP diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 589c5792..b64b184e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,6 +217,46 @@ jobs: run: | sudo USE_RDMA=1 make install + cares: + name: Build with c-ares ${{ matrix.sanitizer && format('({0})', matrix.sanitizer) || '' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sanitizer: ['', 'thread', 'undefined', 'leak', 'address'] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Prepare + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: libc-ares-dev libevent-dev valkey-server + version: 1.0 + - name: Build with make + run: USE_CARES=1 make + - name: Build with CMake + run: | + CFLAGS="" + if [ -n "${{ matrix.sanitizer }}" ]; then + CFLAGS="-fno-omit-frame-pointer -fsanitize=${{ matrix.sanitizer }}" + fi + cmake -B build -S . -DCMAKE_BUILD_TYPE=RelWithDebInfo -DENABLE_CARES=ON -DENABLE_IPV6_TESTS=ON -DCMAKE_C_FLAGS="$CFLAGS" + - name: Build + working-directory: build + run: make + - name: Setup clusters + working-directory: build + run: make start + - name: Wait for clusters to start.. + uses: kibertoad/wait-action@99f6f101c5be7b88bb9b41c0d3b810722491b8e5 # 1.0.1 + with: + time: '20s' + - name: Run tests + working-directory: build + run: make test + - name: Teardown clusters + working-directory: build + run: make stop + cmake-minimum-required: name: CMake 3.7.0 (min. required) runs-on: ubuntu-latest diff --git a/CMakeLists.txt b/CMakeLists.txt index eea20d89..f6e6b6a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,7 @@ OPTION(ENABLE_EXAMPLES "Enable building valkey examples" OFF) option(ENABLE_IPV6_TESTS "Enable IPv6 tests requiring special prerequisites" OFF) OPTION(ENABLE_RDMA "Build valkey_rdma for RDMA support" OFF) OPTION(ENABLE_DLOPEN_RDMA "Build valkey_rdma with dynamic loading" OFF) +OPTION(ENABLE_CARES "Build with c-ares for non-blocking DNS" OFF) OPTION(DISABLE_FFC_IMPL "Disable bundled ffc implementation (use external)" OFF) # Libvalkey requires C99 (-std=c99) @@ -48,6 +49,7 @@ set(valkey_sources src/conn.c src/crc16.c src/dict.c + src/dns.c src/net.c src/read.c src/sockcompat.c @@ -96,6 +98,25 @@ if(DISABLE_FFC_IMPL) list(APPEND valkey_compile_definitions DISABLE_FFC_IMPL) endif() +if(ENABLE_CARES AND NOT WIN32) + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(CARES IMPORTED_TARGET libcares) + endif() + if(CARES_FOUND) + list(APPEND valkey_link_libraries PkgConfig::CARES) + else() + find_library(CARES_LIBRARY NAMES cares) + find_path(CARES_INCLUDE_DIR ares.h) + if(CARES_LIBRARY AND CARES_INCLUDE_DIR) + list(APPEND valkey_link_libraries ${CARES_LIBRARY}) + include_directories(${CARES_INCLUDE_DIR}) + else() + message(FATAL_ERROR "c-ares not found. Install libcares or disable ENABLE_CARES.") + endif() + endif() +endif() + if(valkey_link_libraries) target_link_libraries(valkey PUBLIC ${valkey_link_libraries}) endif() @@ -104,6 +125,10 @@ if(valkey_compile_definitions) target_compile_definitions(valkey PRIVATE ${valkey_compile_definitions}) endif() +if(ENABLE_CARES AND NOT WIN32) + target_compile_definitions(valkey PUBLIC VALKEY_USE_CARES) +endif() + TARGET_INCLUDE_DIRECTORIES(valkey PUBLIC $ @@ -343,6 +368,9 @@ if(NOT DISABLE_TESTS) if(valkey_compile_definitions) target_compile_definitions(valkey_unittest PRIVATE ${valkey_compile_definitions}) endif() + if(ENABLE_CARES AND NOT WIN32) + target_compile_definitions(valkey_unittest PRIVATE VALKEY_USE_CARES) + endif() if(valkey_link_libraries) target_link_libraries(valkey_unittest PUBLIC ${valkey_link_libraries}) endif() diff --git a/Makefile b/Makefile index 52a2b0d0..5e898ffb 100644 --- a/Makefile +++ b/Makefile @@ -159,6 +159,17 @@ else endif ##################### RDMA variables end ##################### +#################### c-ares variables start #################### +USE_CARES?=0 + +ifeq ($(USE_CARES),1) + CFLAGS+=-DVALKEY_USE_CARES + CARES_LDFLAGS=-lcares +else + CARES_LDFLAGS= +endif +##################### c-ares variables end ##################### + # Platform-specific overrides uname_S := $(shell uname -s 2>/dev/null || echo not) @@ -244,7 +255,7 @@ else ifeq ($(uname_S),Darwin) -Wl,-install_name,$(PREFIX)/$(LIBRARY_PATH)/$(TLS_DYLIB_PATCH_NAME) endif -REAL_LDFLAGS += $(PTHREAD_FLAGS) +REAL_LDFLAGS += $(PTHREAD_FLAGS) $(CARES_LDFLAGS) all: dynamic static pkgconfig tests diff --git a/README.md b/README.md index c6ddf543..2705d4b5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Libvalkey is the official C client for the [Valkey](https://valkey.io) database. - Supports both `RESP2` and `RESP3` protocol versions. - Supports both synchronous and asynchronous operation. - Optional support for `MPTCP`, `TLS` and `RDMA` connections. +- Optional timeout-bounded DNS resolution via [c-ares](https://github.com/c-ares/c-ares). - Asynchronous API with several event libraries to choose from. - Supports both standalone and cluster mode operation. - Can be compiled with either `make` or `CMake`. @@ -42,7 +43,7 @@ We support plain GNU make and CMake. Following is information on how to build th sudo make install # With all options -sudo USE_TLS=1 USE_RDMA=1 make install +sudo USE_TLS=1 USE_RDMA=1 USE_CARES=1 make install # If your openssl is in a non-default location sudo USE_TLS=1 OPENSSL_PREFIX=/path/to/openssl make install @@ -58,9 +59,9 @@ mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo .. sudo make install -# Build with TLS and RDMA support +# Build with TLS, RDMA, and c-ares support mkdir build && cd build -cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo -DENABLE_TLS=1 -DENABLE_RDMA=1 .. +cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo -DENABLE_TLS=1 -DENABLE_RDMA=1 -DENABLE_CARES=1 .. sudo make install ``` diff --git a/docs/cluster.md b/docs/cluster.md index 4b786f84..ed738912 100644 --- a/docs/cluster.md +++ b/docs/cluster.md @@ -82,7 +82,7 @@ There are also several flags you can specify in `valkeyClusterOptions.options`. | `VALKEY_OPT_USE_REPLICAS` | Tells libvalkey to keep parsed information of replica nodes. | | `VALKEY_OPT_BLOCKING_INITIAL_UPDATE` | **ASYNC**: Tells libvalkey to perform the initial slot map update in a blocking fashion. The function call will wait for a slot map update before returning so that the returned context is immediately ready to accept commands. | | `VALKEY_OPT_REUSEADDR` | Tells libvalkey to set the [SO_REUSEADDR](https://man7.org/linux/man-pages/man7/socket.7.html) socket option | -| `VALKEY_OPT_PREFER_IPV4`
`VALKEY_OPT_PREFER_IPV6`
`VALKEY_OPT_PREFER_IP_UNSPEC` | Informs libvalkey to either prefer IPv4 or IPv6 when invoking [getaddrinfo](https://man7.org/linux/man-pages/man3/gai_strerror.3.html). `VALKEY_OPT_PREFER_IP_UNSPEC` will cause libvalkey to specify `AF_UNSPEC` in the getaddrinfo call, which means both IPv4 and IPv6 addresses will be searched simultaneously.
Libvalkey prefers IPv4 by default. | +| `VALKEY_OPT_PREFER_IPV4`
`VALKEY_OPT_PREFER_IPV6`
`VALKEY_OPT_PREFER_IP_UNSPEC` | Informs libvalkey to either prefer IPv4 or IPv6 when performing DNS resolution. `VALKEY_OPT_PREFER_IP_UNSPEC` will cause libvalkey to resolve both IPv4 and IPv6 addresses simultaneously.
Libvalkey prefers IPv4 by default. | | `VALKEY_OPT_MPTCP` | Tells libvalkey to use multipath TCP (MPTCP). Note that only when both the server and client are using MPTCP do they establish an MPTCP connection between them; otherwise, they use a regular TCP connection instead. | ### Executing commands diff --git a/docs/standalone.md b/docs/standalone.md index b6045fdc..82b666e0 100644 --- a/docs/standalone.md +++ b/docs/standalone.md @@ -45,6 +45,12 @@ When connecting to a server, libvalkey will return `NULL` in the event that we c When a hostname resolves to multiple addresses, libvalkey will try each address in order until one succeeds or all have failed. Note that the `connect_timeout` applies per address, so the total connection time may be up to N × timeout when multiple addresses are unreachable. +#### DNS resolution with c-ares + +When built with c-ares support (`USE_CARES=1` / `-DENABLE_CARES=1`), DNS resolution uses c-ares instead of `getaddrinfo()`. This provides timeout-bounded DNS resolution using `connect_timeout` (defaulting to 5 seconds if unset), preventing indefinite hangs when DNS servers are slow or unreachable. + +c-ares support is available on Linux, macOS, and FreeBSD (not Windows). + ```c valkeyContext *ctx = valkeyConnect("localhost", 6379); if (ctx == NULL || ctx->err) { @@ -76,7 +82,7 @@ There are also several flags you can specify when using the `valkeyOptions` help | --- | --- | | `VALKEY_OPT_NONBLOCK` | Tells libvalkey to make a non-blocking connection. | | `VALKEY_OPT_REUSEADDR` | Tells libvalkey to set the [SO_REUSEADDR](https://man7.org/linux/man-pages/man7/socket.7.html) socket option | -| `VALKEY_OPT_PREFER_IPV4`
`VALKEY_OPT_PREFER_IPV6`
`VALKEY_OPT_PREFER_IP_UNSPEC` | Informs libvalkey to either prefer IPv4 or IPv6 when invoking [getaddrinfo](https://man7.org/linux/man-pages/man3/gai_strerror.3.html). `VALKEY_OPT_PREFER_IP_UNSPEC` will cause libvalkey to specify `AF_UNSPEC` in the getaddrinfo call, which means both IPv4 and IPv6 addresses will be searched simultaneously.
Libvalkey prefers IPv4 by default. | +| `VALKEY_OPT_PREFER_IPV4`
`VALKEY_OPT_PREFER_IPV6`
`VALKEY_OPT_PREFER_IP_UNSPEC` | Informs libvalkey to either prefer IPv4 or IPv6 when performing DNS resolution. `VALKEY_OPT_PREFER_IP_UNSPEC` will resolve both IPv4 and IPv6 addresses simultaneously.
Libvalkey prefers IPv4 by default. | | `VALKEY_OPT_NO_PUSH_AUTOFREE` | Tells libvalkey to not install the default RESP3 PUSH handler (which just intercepts and frees the replies). This is useful in situations where you want to process these messages in-band. | | `VALKEY_OPT_NOAUTOFREEREPLIES` | **ASYNC**: tells libvalkey not to automatically invoke `freeReplyObject` after executing the reply callback. | | `VALKEY_OPT_NOAUTOFREE` | **ASYNC**: Tells libvalkey not to automatically free the `valkeyAsyncContext` on connection/communication failure, but only if the user makes an explicit call to `valkeyAsyncDisconnect` or `valkeyAsyncFree` | diff --git a/src/dns.c b/src/dns.c new file mode 100644 index 00000000..72b5aad6 --- /dev/null +++ b/src/dns.c @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2026, the libvalkey contributors + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of the copyright holder nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include "fmacros.h" +#include "win32.h" + +#include "dns.h" + +#include +#include + +#ifdef VALKEY_USE_CARES +#include "alloc.h" + +#include + +#if ARES_VERSION < 0x011000 +#error "c-ares >= 1.16.0 is required for ares_getaddrinfo" +#endif + +#include +#include +#include +#include + +/* Default DNS timeout when no connect_timeout is set (5 seconds). */ +#define VALKEY_DNS_DEFAULT_TIMEOUT_MS 5000 + +static pthread_once_t cares_init_once = PTHREAD_ONCE_INIT; + +static void valkeyCaresLibraryInit(void) { + /* Use system malloc for c-ares rather than libvalkey's allocators. + * c-ares leaks internally when custom allocators return NULL during OOM, + * and its allocations are small and short-lived (freed per-resolve). + * TODO: switch to ares_library_init_mem() if c-ares fixes OOM handling. */ + ares_library_init(ARES_LIB_INIT_NONE); +} + +static long valkeyDnsPollMillis(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (now.tv_sec * 1000) + now.tv_nsec / 1000000; +} + +/* Callback state for synchronous ares_getaddrinfo. */ +struct caresResult { + int done; + int status; + struct ares_addrinfo *ai; +}; + +/* c-ares callback invoked when ares_getaddrinfo completes. */ +static void caresCallback(void *arg, int status, int timeouts, struct ares_addrinfo *res) { + struct caresResult *r = (struct caresResult *)arg; + (void)timeouts; + r->done = 1; + r->status = status; + r->ai = res; +} + +/* State for tracking c-ares socket interest via ARES_OPT_SOCK_STATE_CB. */ +struct caresSockState { + struct pollfd pfds[ARES_GETSOCK_MAXNUM]; + int nfds; +}; + +/* c-ares socket state callback (ARES_OPT_SOCK_STATE_CB). Tracks which fds + * c-ares needs polled so the sync poll loop knows what to watch. */ +static void caresSockStateCb(void *data, ares_socket_t fd, + int readable, int writable) { + struct caresSockState *st = (struct caresSockState *)data; + int i; + + if (!readable && !writable) { + /* Remove fd. */ + for (i = 0; i < st->nfds; i++) { + if (st->pfds[i].fd == fd) { + st->pfds[i] = st->pfds[st->nfds - 1]; + st->nfds--; + break; + } + } + return; + } + + /* Find existing or add new. */ + for (i = 0; i < st->nfds; i++) { + if (st->pfds[i].fd == fd) + break; + } + if (i == st->nfds) { + if (st->nfds >= ARES_GETSOCK_MAXNUM) + return; + st->nfds++; + } + st->pfds[i].fd = fd; + st->pfds[i].events = 0; + if (readable) + st->pfds[i].events |= POLLIN; + if (writable) + st->pfds[i].events |= POLLOUT; + st->pfds[i].revents = 0; +} + +/* Convert ares_addrinfo to struct addrinfo. Caller must use valkeyFreeAddrInfo() + * to free. Returns 0 on success, -1 on failure (OOM or empty result). */ +static int caresAddrInfoToAddrInfo(struct ares_addrinfo *cai, + struct addrinfo **out) { + struct addrinfo *head = NULL, *tail = NULL; + struct ares_addrinfo_node *node; + + for (node = cai->nodes; node != NULL; node = node->ai_next) { + struct addrinfo *ai = vk_calloc(1, sizeof(*ai) + node->ai_addrlen); + if (ai == NULL) { + while (head) { + struct addrinfo *next = head->ai_next; + vk_free(head); + head = next; + } + return -1; + } + ai->ai_family = node->ai_family; + ai->ai_socktype = node->ai_socktype; + ai->ai_protocol = node->ai_protocol; + ai->ai_addrlen = node->ai_addrlen; + ai->ai_addr = (struct sockaddr *)((char *)ai + sizeof(*ai)); + memcpy(ai->ai_addr, node->ai_addr, node->ai_addrlen); + ai->ai_next = NULL; + + if (tail) + tail->ai_next = ai; + else + head = ai; + tail = ai; + } + if (head == NULL) + return -1; + *out = head; + return 0; +} + +void valkeyFreeAddrInfo(struct addrinfo *ai) { + while (ai) { + struct addrinfo *next = ai->ai_next; + vk_free(ai); + ai = next; + } +} + +/* Map c-ares status to getaddrinfo error codes for consistent error reporting. */ +static int caresStatusToEai(int status) { + switch (status) { + case ARES_ENOTFOUND: + case ARES_ENODATA: + return EAI_NONAME; + case ARES_ETIMEOUT: + case ARES_ECANCELLED: + return EAI_AGAIN; + case ARES_ENOMEM: + return EAI_MEMORY; + default: + return EAI_FAIL; + } +} + +/* Drive c-ares poll loop until res->done or deadline exceeded. */ +static void caresPollLoop(ares_channel_t *channel, struct caresSockState *st, + struct caresResult *res, long deadline) { + while (!res->done) { + if (st->nfds == 0) + break; + + long now = valkeyDnsPollMillis(); + long remaining = deadline - now; + if (remaining <= 0) { + ares_cancel(channel); + break; + } + + struct timeval maxtv, tv; + maxtv.tv_sec = remaining / 1000; + maxtv.tv_usec = (remaining % 1000) * 1000; + struct timeval *tvp = ares_timeout(channel, &maxtv, &tv); + long lval_ms = tvp->tv_sec * 1000 + tvp->tv_usec / 1000; + if (lval_ms <= 0) + lval_ms = 1; + else if (lval_ms > INT_MAX) + lval_ms = INT_MAX; + int poll_ms = (int)lval_ms; + + int ret = poll(st->pfds, (nfds_t)st->nfds, poll_ms); + if (ret > 0) { + for (int i = 0; i < st->nfds; i++) { + ares_socket_t rfd = (st->pfds[i].revents & (POLLIN | POLLERR | POLLHUP)) ? st->pfds[i].fd : ARES_SOCKET_BAD; + ares_socket_t wfd = (st->pfds[i].revents & POLLOUT) ? st->pfds[i].fd : ARES_SOCKET_BAD; + ares_process_fd(channel, rfd, wfd); + } + } else { + ares_process_fd(channel, ARES_SOCKET_BAD, ARES_SOCKET_BAD); + } + } +} + +/* Resolve hostname using c-ares with a poll loop bounded by timeout_ms. + * Returns 0 on success (result set), or a getaddrinfo-compatible error code. */ +static int valkeyResolveCares(const char *host, int port, int flags, + long timeout_ms, struct addrinfo **result) { + ares_channel_t *channel = NULL; + struct ares_options opts; + struct ares_addrinfo_hints hints; + struct caresResult res = {0, 0, NULL}; + struct caresSockState sockstate = {{{0}}, 0}; + int optmask; + int rv; + long effective_timeout = timeout_ms; + if (effective_timeout <= 0 || effective_timeout >= INT_MAX) + effective_timeout = VALKEY_DNS_DEFAULT_TIMEOUT_MS; + + pthread_once(&cares_init_once, valkeyCaresLibraryInit); + + memset(&opts, 0, sizeof(opts)); + opts.timeout = (int)effective_timeout; + opts.tries = 2; + opts.sock_state_cb = caresSockStateCb; + opts.sock_state_cb_data = &sockstate; + optmask = ARES_OPT_TIMEOUTMS | ARES_OPT_TRIES | ARES_OPT_SOCK_STATE_CB; + + rv = ares_init_options(&channel, &opts, optmask); + if (rv != ARES_SUCCESS) + return (rv == ARES_ENOMEM) ? EAI_MEMORY : EAI_FAIL; + + memset(&hints, 0, sizeof(hints)); + hints.ai_socktype = SOCK_STREAM; + + if ((flags & VALKEY_PREFER_IPV6) && (flags & VALKEY_PREFER_IPV4)) + hints.ai_family = AF_UNSPEC; + else if (flags & VALKEY_PREFER_IPV6) + hints.ai_family = AF_INET6; + else if (strchr(host, ':') != NULL) + hints.ai_family = AF_INET6; /* IPv6 literal */ + else + hints.ai_family = AF_INET; + + char portstr[6]; + snprintf(portstr, sizeof(portstr), "%d", port); + + ares_getaddrinfo(channel, host, portstr, &hints, caresCallback, &res); + + long deadline = valkeyDnsPollMillis() + effective_timeout; + caresPollLoop(channel, &sockstate, &res, deadline); + + rv = EAI_FAIL; + if (res.done && res.status == ARES_SUCCESS && res.ai) { + if (caresAddrInfoToAddrInfo(res.ai, result) == 0) + rv = 0; + else + rv = EAI_MEMORY; + } else if (res.done && (res.status == ARES_ENOTFOUND || res.status == ARES_ENODATA) && + hints.ai_family != AF_UNSPEC) { + /* ENOTFOUND: domain doesn't exist (NXDOMAIN). + * ENODATA: domain exists but has no records for the requested family. + * In both cases, retry with the other address family. */ + if (res.ai) + ares_freeaddrinfo(res.ai); + res.ai = NULL; + + hints.ai_family = (hints.ai_family == AF_INET) ? AF_INET6 : AF_INET; + res.done = 0; + res.status = 0; + + ares_getaddrinfo(channel, host, portstr, &hints, caresCallback, &res); + deadline = valkeyDnsPollMillis() + effective_timeout; + caresPollLoop(channel, &sockstate, &res, deadline); + + if (res.done && res.status == ARES_SUCCESS && res.ai) { + if (caresAddrInfoToAddrInfo(res.ai, result) == 0) + rv = 0; + else + rv = EAI_MEMORY; + } else { + /* Retry also failed. */ + rv = caresStatusToEai(res.status); + } + } else { + /* First attempt failed with non-retryable error. */ + rv = caresStatusToEai(res.status); + } + + if (res.ai) + ares_freeaddrinfo(res.ai); + ares_destroy(channel); + return rv; +} +#endif /* VALKEY_USE_CARES */ + +int valkeyResolveSync(const char *host, int port, int flags, + long timeout_ms, struct addrinfo **result) { +#ifdef VALKEY_USE_CARES + return valkeyResolveCares(host, port, flags, timeout_ms, result); +#else + (void)timeout_ms; + char portstr[6]; /* strlen("65535") + 1 */ + struct addrinfo hints; + int rv; + + snprintf(portstr, sizeof(portstr), "%d", port); + memset(&hints, 0, sizeof(hints)); + hints.ai_socktype = SOCK_STREAM; + + /* Determine address family from flags. By default, try IPv4 first and + * fall back to IPv6. If both PREFER flags are set, use AF_UNSPEC. */ + if ((flags & VALKEY_PREFER_IPV6) && (flags & VALKEY_PREFER_IPV4)) + hints.ai_family = AF_UNSPEC; + else if (flags & VALKEY_PREFER_IPV6) + hints.ai_family = AF_INET6; + else + hints.ai_family = AF_INET; + + rv = getaddrinfo(host, portstr, &hints, result); + if (rv != 0 && hints.ai_family != AF_UNSPEC) { + /* Try again with the other IP version. */ + hints.ai_family = (hints.ai_family == AF_INET) ? AF_INET6 : AF_INET; + rv = getaddrinfo(host, portstr, &hints, result); + } + return rv; +#endif +} diff --git a/src/dns.h b/src/dns.h new file mode 100644 index 00000000..8cb7b933 --- /dev/null +++ b/src/dns.h @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, the libvalkey contributors + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of the copyright holder nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef VALKEY_DNS_H +#define VALKEY_DNS_H + +#include "sockcompat.h" +#include "valkey.h" + +/* Resolve hostname synchronously. Returns 0 on success. + * On failure returns a getaddrinfo error code; the caller can use + * gai_strerror() to get the error message. + * The caller must free the result with valkeyFreeAddrInfo(). + * flags: context flags (VALKEY_PREFER_IPV4, VALKEY_PREFER_IPV6). + * timeout_ms: DNS resolution timeout in milliseconds (used with c-ares). */ +int valkeyResolveSync(const char *host, int port, int flags, + long timeout_ms, struct addrinfo **result); + +/* Free addrinfo returned by valkeyResolveSync. Safe to call on either + * freeaddrinfo-compatible or c-ares-allocated results. */ +#ifdef VALKEY_USE_CARES +void valkeyFreeAddrInfo(struct addrinfo *ai); +#else +#define valkeyFreeAddrInfo(ai) freeaddrinfo(ai) +#endif + +#endif /* VALKEY_DNS_H */ diff --git a/src/net.c b/src/net.c index 7201583e..97c14d6b 100644 --- a/src/net.c +++ b/src/net.c @@ -38,6 +38,7 @@ #include "net.h" #include "async.h" +#include "dns.h" #include "sockcompat.h" #include "valkey_private.h" @@ -398,8 +399,7 @@ int valkeyContextConnectTcp(valkeyContext *c, const valkeyOptions *options) { int port = options->endpoint.tcp.port; valkeyFD s; int rv, n; - char _port[6]; /* strlen("65535"); */ - struct addrinfo hints, *servinfo, *bservinfo, *p, *b; + struct addrinfo *servinfo, *bservinfo, *p, *b; int blocking = (c->flags & VALKEY_BLOCK); int reuseaddr = (c->flags & VALKEY_REUSEADDR); int reuses = 0; @@ -444,28 +444,13 @@ int valkeyContextConnectTcp(valkeyContext *c, const valkeyOptions *options) { c->tcp.source_addr = vk_strdup(source_addr); } - snprintf(_port, 6, "%d", port); - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - - /* DNS lookup. To use dual stack, set both flags to prefer both IPv4 and - * IPv6. By default, for historical reasons, we try IPv4 first and then we - * try IPv6 only if no IPv4 address was found. */ - if (c->flags & VALKEY_PREFER_IPV6 && c->flags & VALKEY_PREFER_IPV4) - hints.ai_family = AF_UNSPEC; - else if (c->flags & VALKEY_PREFER_IPV6) - hints.ai_family = AF_INET6; - else - hints.ai_family = AF_INET; - - rv = getaddrinfo(c->tcp.host, _port, &hints, &servinfo); - if (rv != 0 && hints.ai_family != AF_UNSPEC) { - /* Try again with the other IP version. */ - hints.ai_family = (hints.ai_family == AF_INET) ? AF_INET6 : AF_INET; - rv = getaddrinfo(c->tcp.host, _port, &hints, &servinfo); - } + /* DNS lookup */ + /* TODO: Decide if DNS + TCP connect should share a single connect_timeout + * budget rather than each getting the full timeout independently. */ + rv = valkeyResolveSync(c->tcp.host, port, c->flags, timeout_msec, &servinfo); if (rv != 0) { + if (rv == EAI_MEMORY) + goto oom; valkeySetError(c, VALKEY_ERR_OTHER, gai_strerror(rv)); return VALKEY_ERR; } @@ -480,6 +465,9 @@ int valkeyContextConnectTcp(valkeyContext *c, const valkeyOptions *options) { goto error; if (c->tcp.source_addr) { int bound = 0; + struct addrinfo hints = {0}; + hints.ai_family = p->ai_family; + hints.ai_socktype = p->ai_socktype; /* Using getaddrinfo saves us from self-determining IPv4 vs IPv6 */ if ((rv = getaddrinfo(c->tcp.source_addr, NULL, &hints, &bservinfo)) != 0) { char buf[128]; @@ -562,7 +550,7 @@ int valkeyContextConnectTcp(valkeyContext *c, const valkeyOptions *options) { rv = VALKEY_ERR; end: if (servinfo) { - freeaddrinfo(servinfo); + valkeyFreeAddrInfo(servinfo); } return rv; // Need to return VALKEY_OK if alright diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7ff1ef99..ad7c2e20 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -97,13 +97,19 @@ target_include_directories(ut_slotmap_update PRIVATE "${PROJECT_SOURCE_DIR}/src" target_link_libraries(ut_slotmap_update valkey_unittest) add_test(NAME ut_slotmap_update COMMAND "$") -if(NOT WIN32 AND NOT CYGWIN) +if(NOT WIN32 AND NOT CYGWIN AND NOT ENABLE_CARES) add_executable(ut_connect_fallback ut_connect_fallback.c) target_compile_options(ut_connect_fallback PRIVATE -Wno-pedantic) target_link_libraries(ut_connect_fallback valkey_unittest dl) add_test(NAME ut_connect_fallback COMMAND "$") endif() +if(ENABLE_CARES AND NOT WIN32) + add_executable(ut_dns_cares ut_dns_cares.c) + target_link_libraries(ut_dns_cares valkey_unittest) + add_test(NAME ut_dns_cares COMMAND "$") +endif() + # Cluster tests add_executable(ct_commands ct_commands.c test_utils.c) target_link_libraries(ct_commands valkey ${TLS_LIBRARY}) diff --git a/tests/ct_out_of_memory_handling.c b/tests/ct_out_of_memory_handling.c index a7131819..8fbf41a3 100644 --- a/tests/ct_out_of_memory_handling.c +++ b/tests/ct_out_of_memory_handling.c @@ -129,8 +129,17 @@ void test_alloc_failure_handling(void) { } // Skip iteration 100 to 159 since sdscatfmt give leak warnings during OOM. - successfulAllocations = 160; - cc = valkeyClusterConnectWithOptions(&options); + /* Discover the number of allocations required for a successful connect. + * Without c-ares this is 160, with c-ares it's ~205. */ + for (int i = 200; i < 1000; ++i) { + successfulAllocations = i; + cc = valkeyClusterConnectWithOptions(&options); + assert(cc); + if (cc->err == 0) + break; + ASSERT_STR_EQ(cc->errstr, "Out of memory"); + valkeyClusterFree(cc); + } assert(cc && cc->err == 0); } @@ -457,8 +466,17 @@ void test_alloc_failure_handling_async(void) { } // Skip iteration 100 to 156 since sdscatfmt give leak warnings during OOM. - successfulAllocations = 157; - acc = valkeyClusterAsyncConnectWithOptions(&options); + /* Discover the number of allocations required for a successful async connect. + * Without c-ares this is 157, with c-ares it's ~205. */ + for (int i = 200; i < 1000; ++i) { + successfulAllocations = i; + acc = valkeyClusterAsyncConnectWithOptions(&options); + assert(acc != NULL); + if (acc->err == 0) + break; + ASSERT_STR_EQ(acc->errstr, "Out of memory"); + valkeyClusterAsyncFree(acc); + } assert(acc && acc->err == 0); } @@ -467,19 +485,15 @@ void test_alloc_failure_handling_async(void) { { const char *cmd1 = "SET foo one"; - for (int i = 0; i < 36; ++i) { - prepare_allocation_test_async(acc, i); + for (int n = 0; n < 1000; ++n) { + prepare_allocation_test_async(acc, n); result = valkeyClusterAsyncCommand(acc, commandCallback, &r1, cmd1); + if (result == VALKEY_OK) + break; assert(result == VALKEY_ERR); - if (i != 34) { - ASSERT_STR_EQ(acc->errstr, "Out of memory"); - } else { - ASSERT_STR_EQ(acc->errstr, "Failed to attach event adapter"); - } + assert(strcmp(acc->errstr, "Out of memory") == 0 || + strcmp(acc->errstr, "Failed to attach event adapter") == 0); } - - prepare_allocation_test_async(acc, 35); - result = valkeyClusterAsyncCommand(acc, commandCallback, &r1, cmd1); ASSERT_MSG(result == VALKEY_OK, acc->errstr); } @@ -489,17 +503,18 @@ void test_alloc_failure_handling_async(void) { { const char *cmd2 = "GET foo"; - for (int i = 0; i < 12; ++i) { - prepare_allocation_test_async(acc, i); + for (int n = 0; n < 1000; ++n) { + /* Skip iteration 12, errstr not set by libvalkey when + * valkeyFormatSdsCommandArgv() fails. */ + if (n == 12) + continue; + prepare_allocation_test_async(acc, n); result = valkeyClusterAsyncCommand(acc, commandCallback, &r2, cmd2); + if (result == VALKEY_OK) + break; assert(result == VALKEY_ERR); ASSERT_STR_EQ(acc->errstr, "Out of memory"); } - - /* Skip iteration 12, errstr not set by libvalkey when valkeyFormatSdsCommandArgv() fails. */ - - prepare_allocation_test_async(acc, 13); - result = valkeyClusterAsyncCommand(acc, commandCallback, &r2, cmd2); ASSERT_MSG(result == VALKEY_OK, acc->errstr); } diff --git a/tests/ut_dns_cares.c b/tests/ut_dns_cares.c new file mode 100644 index 00000000..bddc3696 --- /dev/null +++ b/tests/ut_dns_cares.c @@ -0,0 +1,104 @@ +/* + * Unit tests for c-ares DNS resolution (src/dns.c). + * + * Tests valkeyResolveSync() with c-ares backend: + * - Resolve an IP literal (127.0.0.1) + * - Resolve "localhost" + * - Resolve a non-existent hostname (expect failure) + * - Resolve with IPv6 preference + * - Timeout behavior + */ + +#define _POSIX_C_SOURCE 200112L + +#include "valkey.h" + +#include +#include +#include +#include + +/* Declarations for internal functions under test. */ +int valkeyResolveSync(const char *host, int port, int flags, + long timeout_ms, struct addrinfo **result); +void valkeyFreeAddrInfo(struct addrinfo *ai); + +/* Test: resolving an IP literal should succeed immediately. */ +static void test_resolve_ip_literal(void) { + struct addrinfo *result = NULL; + int rv = valkeyResolveSync("127.0.0.1", 6379, 0, 5000, &result); + assert(rv == 0); + assert(result != NULL); + assert(result->ai_family == AF_INET); + assert(result->ai_socktype == SOCK_STREAM); + valkeyFreeAddrInfo(result); + printf(" PASS: test_resolve_ip_literal\n"); +} + +/* Test: resolving "localhost" should succeed. */ +static void test_resolve_localhost(void) { + struct addrinfo *result = NULL; + int rv = valkeyResolveSync("localhost", 6379, 0, 5000, &result); + assert(rv == 0); + assert(result != NULL); + assert(result->ai_socktype == SOCK_STREAM); + valkeyFreeAddrInfo(result); + printf(" PASS: test_resolve_localhost\n"); +} + +/* Test: resolving a non-existent hostname should fail. */ +static void test_resolve_nonexistent(void) { + struct addrinfo *result = NULL; + int rv = valkeyResolveSync("this.host.does.not.exist.invalid", 6379, + 0, 2000, &result); + assert(rv != 0); + assert(result == NULL); + printf(" PASS: test_resolve_nonexistent\n"); +} + +/* Test: resolving with VALKEY_PREFER_IPV6 flag. */ +static void test_resolve_ipv6_preference(void) { + struct addrinfo *result = NULL; + int rv = valkeyResolveSync("localhost", 6379, VALKEY_PREFER_IPV6, 5000, &result); + /* May succeed or fail depending on system config (IPv6 availability). */ + if (rv == 0 && result != NULL) { + assert(result->ai_family == AF_INET6 || result->ai_family == AF_INET); + valkeyFreeAddrInfo(result); + } + printf(" PASS: test_resolve_ipv6_preference\n"); +} + +/* Test: DNS timeout with very short timeout. + * .example is reserved (RFC 2606) and guaranteed to never resolve, + * forcing c-ares to actually send queries that time out. */ +static void test_resolve_timeout(void) { + struct addrinfo *result = NULL; + int rv = valkeyResolveSync("timeout-test.example", 6379, 0, 1, &result); + assert(rv != 0); + assert(result == NULL); + printf(" PASS: test_resolve_timeout\n"); +} + +/* Test: resolving with both IPv4 and IPv6 (AF_UNSPEC). */ +static void test_resolve_unspec(void) { + struct addrinfo *result = NULL; + int flags = VALKEY_PREFER_IPV4 | VALKEY_PREFER_IPV6; + int rv = valkeyResolveSync("localhost", 6379, flags, 5000, &result); + if (rv == 0) { + assert(result != NULL); + valkeyFreeAddrInfo(result); + } + printf(" PASS: test_resolve_unspec\n"); +} + +int main(void) { + printf("Testing c-ares DNS resolution:\n"); + test_resolve_ip_literal(); + test_resolve_localhost(); + test_resolve_nonexistent(); + test_resolve_ipv6_preference(); + test_resolve_timeout(); + test_resolve_unspec(); + printf("All DNS tests passed.\n"); + return 0; +}