cares-sys 0.0.1

Threadless static CMake build and modern FFI bindings for c-ares
docs.rs failed to build cares-sys-0.0.1
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

cares-rs

Safe, threadless, completion-driven Rust 2024 bindings for upstream c-ares, built on liburing-rs. c-ares is compiled with CARES_THREADS=OFF; there is no executor runtime, background event thread, or readiness reactor.

Why cares-rs?

Name resolution sits before the first connection, but it can also interrupt an otherwise healthy client when a pool reconnects or an endpoint changes. A hostname lookup through getaddrinfo or std::net::ToSocketAddrs can block the calling thread. On a worker responsible for many live connections, that pause holds up unrelated traffic as well as the connection being opened.

An asynchronous resolver avoids that blocking call, but changes how the client must run. For example, Hickory's Tokio integration drives lookups as futures on a runtime. Adopting that integration in a synchronous client means propagating asynchronous signatures or bridging into an executor; its task scheduling also becomes part of the connection's latency budget. Thread-enabled c-ares builds offer another integration model, with channel locking and optional event-thread processing. A worker that already owns its I/O loop needs DNS progress to fit that loop instead.

cares-rs builds c-ares with CARES_THREADS=OFF and keeps each resolver on one owner thread. In shared-ring applications, ProactorResolver submits socket interests and retry timers to the caller's existing liburing::Proactor. Answers, UDP-to-TCP fallback, timeouts, and follow-up queries advance through the same completion dispatcher as the client's other I/O. There is no resolver thread to synchronize with, and the caller keeps control of the timeout budget and of how long shutdown may drain pending work.

That makes a failed lookup easier to inspect. A breakpoint can follow query submission, socket or timer completion, and the callback on the same thread. Borrowed DNS records are scoped to that callback, while ChannelCtx makes the allowed follow-up operations explicit. Engineers and AI coding tools can reason about these visible lifetimes without carrying resolver state across .await boundaries or guessing when a background task will release it.

Design

  • Threadless c-ares. The native library is built with CARES_THREADS=OFF and CARES_SHARED=OFF; it contains no pthread mutexes, no event thread, and no ARES_OPT_EVENT_THREAD.
  • Single-thread resolver. Resolver is !Send + !Sync. Socket readiness, retry deadlines, and user callbacks all run on the thread that owns it.
  • Two completion drivers. Standalone mode uses wait_completions, poll_completions, or resolve. Shared-ring mode keeps ProactorResolver in caller-owned state and passes a static CompletionDispatcher to the pure Rust liburing::Proactor. DNS-specific POLL_ADD readiness handling stays inside cares; the ring holds no resolver or handler registry.
  • Safe callback boundary. DnsRecordRef<'cb> cannot escape its callback. ChannelCtx<'cb> exposes only re-entrant-safe operations, and panics are caught at the FFI boundary and poison the resolver instead of unwinding into C.

Library initialization uses atomic first/last-reference transitions. Resolver handles remain confined to their owner thread; socket progress does not wait on global initialization state.

All callbacks run synchronously on the thread that drives the resolver. A callback may submit follow-up queries through ChannelCtx; it cannot recursively drive the resolver.

The workspace targets Linux x86_64 and pins a nightly toolchain in rust-toolchain.toml.

Crates

Crate Purpose
cares-sys FFI bindings and the static c-ares build.
cares-macros DnsRecordExtract and cares_query_table! proc macros.
cares Safe resolver, record models, and the proactor driver.

The crates are path dependencies in one Cargo workspace; crates/cares/tests/ holds the integration suites.

Quick Start

use std::time::Duration;

use cares::{DnsRecordType, DnsSection, Resolver, ResolverOptions};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let options = ResolverOptions::builder()
        .tries(2)
        .timeout(Duration::from_secs(2))
        .build()?;
    let mut resolver = Resolver::new(options)?;
    resolver.set_servers_csv("1.1.1.1")?;
    // `resolve` drives the channel to completion or deadline.
    let record = resolver.resolve("example.com", DnsRecordType::A, Duration::from_secs(5))?;
    let address = record.rr(DnsSection::Answer, 0)?.as_a()?.address;
    println!("example.com -> {address}");
    Ok(())
}

Callbacks receive a ChannelCtx and a borrowed-or-owned record; use submit_query for completion-driven work.

Examples

Three self-contained examples under crates/cares/examples/ need no external network:

cargo run -p cares --example dns_packet_codec_and_edns
cargo run -p cares --example standalone_resolver_and_records
cargo run -p cares --example proactor_shared_ring_resolver
  • dns_packet_codec_and_edns builds and parses wire messages with an EDNS0 OPT record and typed answers.
  • standalone_resolver_and_records runs local UDP/TCP queries through wait_completions, including a re-entrant CNAME lookup.
  • proactor_shared_ring_resolver drives ProactorResolver on a real liburing::Proactor; a truncated UDP answer forces TCP fallback and socket-bridge growth. It requires a kernel with io_uring and fails if the ring cannot be created.

The codec example is pure in-memory; the two resolver examples bind a deterministic 127.0.0.1:0 responder. Each prints a short summary and exits with status 0.

Shared-Ring Shutdown

Call driver.flush(&mut ring) after submitting queries, then drive the shared ring with ring.wait_completions(min, timeout, &mut dispatcher). Route the DNS subsystem to driver.dispatch(completion, submissions); the driver remains owned by the caller throughout. Completion routing uses liburing OperationId and OperationPayload; the completed identity is Completion::operation_id.

driver.begin_shutdown(&mut ring) rejects new queries and cancels existing queries and registrations. Continue flush and caller-owned completion dispatch within your remaining deadline until driver.has_pending_io() is false. inflight_count() includes polls, timers, and explicit cancel acknowledgments; a cancel ACK does not retire its target. No poll or timer is rearmed during shutdown. If the deadline expires, retain the entire (ring, driver) owner for later draining. Cancellation controls also need operation/staging credit; peer completions can release it when the shared ring is full.

driver.shutdown_within(&mut ring, budget) and driver.shutdown(&mut ring) (the latter has a five-second budget) drive a DNS-only dispatcher and leave the ring open. Shared-ring applications use their worker dispatcher instead. Close the ring only after every subsystem has retired its I/O. Dropping a driver early retains pending timeout operands because their final CQEs have not proven reuse.

Build and Test

Run every command from the workspace root. GitHub-hosted CI runs the Cargo checks directly.

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked