Skip to content
WindowsDeep Dive Published Updated 9 min readViews unavailable

Windows DnsQueryEx: A Lifetime-Safe Asynchronous DNS Query Pattern

Handle DnsQueryEx's synchronous and asynchronous completions, retain callback state safely, cancel without freeing early, and release DNS records correctly.

DnsQueryEx() is Windows DNSAPI’s generic query interface, and its name can mislead callers into assuming every invocation completes through a callback. The API supports both synchronous and asynchronous execution. If the request omits pQueryCompletionCallback, the call is synchronous; even with a callback configured, documented cases, including a local-machine A/AAAA query, an IPv4/IPv6 address query, or an error, complete synchronously. The decisive signal is the function’s return value: only DNS_REQUEST_PENDING means the result will arrive asynchronously. (DnsQueryEx, DNS_QUERY_REQUEST)

That dual mode makes lifetime management the central engineering problem. The request, result, cancellation structure, callback context, and query-name storage must all remain valid for the path the call actually takes. A robust resolver builds and initializes all of that state before calling DNSAPI, branches on DNS_REQUEST_PENDING, and retires asynchronous state only after the completion callback has run.

Initialize the versioned structures and keep inputs owned

The documented API takes a DNS_QUERY_REQUEST, a DNS_QUERY_RESULT, and an optional DNS_QUERY_CANCEL. Initialize the request’s Version to DNS_QUERY_REQUEST_VERSION1. Initialize the result’s Version to DNS_QUERY_RESULTS_VERSION1 and its other input fields to null/zero as required by the function documentation. Keep the query name and any optional server-list data alive for the duration of the operation. The request holds pointers to the callback and user context, so neither should point to stack-local state if the query may outlive the initiating function.

The request’s QueryType selects the resource-record type and therefore the interpretation of returned records. QueryName == NULL is a documented request for the local machine name; it is not a generic way to request the caller’s arbitrary search suffix. If you need a particular interface, server list, or query option, set those fields deliberately and validate them against the exact windns.h version your target SDK ships. (DNS_QUERY_REQUEST members)

Model each query as a small state machine:

State Owner and action
Submitting The caller owns initialized request/result/cancel objects and all pointed-to input storage; the operation context is registered before entering DnsQueryEx().
Synchronous return DNSAPI does not invoke the registered completion callback for that operation; inspect the output status/records and release them before retiring the context.
Pending DnsQueryEx() returned DNS_REQUEST_PENDING; retain all API-visible structures and the callback context.
Callback Read the final QueryStatus, copy any records needed by another subsystem, free the DNS record list, and publish completion to the owner.
Retired Only after the synchronous path is handled or the asynchronous callback has completed may the backing context be destroyed.

Microsoft documents the synchronous and pending return paths and the completion callback, but does not specify an ordering between the API return and the callback body. Make the state machine tolerate either ordering: the callback may publish completed from submitting or pending; after a DNS_REQUEST_PENDING return, the submitter changes submitting to pending only if the callback has not already published completion. Never overwrite completed with a late pending update. If cancellation is requested while the state is still submitting, record the intent but do not call DNSAPI yet; once the return status is known, dispatch one cancellation only if the query is pending and has not completed. A synchronous return follows the synchronous ownership path and discards any deferred cancellation intent. Microsoft’s sample also takes an extra context reference before starting the asynchronous call, illustrating that callback-visible storage needs independent ownership at submission time. (Microsoft DNS async query sample at the reviewed revision)

The result structure reflects the difference: immediately after an asynchronous return, QueryStatus is DNS_REQUEST_PENDING and pQueryRecords is null; in the callback, the result contains the completed status and any record list. For a synchronous result, the call returns the completed status and the result may contain records directly. In both paths, free returned records with DnsRecordListFree() after their last use. If another thread or UI layer needs data after cleanup, deep-copy the fields it requires rather than keeping a pointer into the DNSAPI-owned list. (DNS_QUERY_RESULT)

Branch on pending, not on the existence of a callback

The safe calling pattern is intentionally explicit. The following lifecycle pseudocode omits application-specific locking and record decoding. context lives in an operation owner that outlives the submitting function; it is not stack-local state that disappears when the caller returns.

auto context = std::make_shared<QueryContext>();
context->queryName = L"example.com";  // owned input storage
initialize_request(context->request);
context->request.QueryName = context->queryName.c_str();
context->request.pQueryContext = context.get();
initialize_result(context->result);    // version set; output members cleared
context->cancel = {};                 // output storage; never edit it after submission
context->operationId = registry.nextId();  // allocate a unique key before submission
const auto operationId = context->operationId;
registry.insert(operationId, context);  // retain shared ownership before submission

DNS_STATUS status = DnsQueryEx(
    &context->request,
    &context->result,
    &context->cancel);

if (status == DNS_REQUEST_PENDING) {
    // The registry keeps context and QueryName alive until the callback retires it.
    // The callback removes operationId only after processing the result.
} else {
    // No callback owns this result. Inspect status and any result records now.
    consume_or_copy_records(context->result);
    if (context->result.pQueryRecords != nullptr)
        DnsRecordListFree(context->result.pQueryRecords, DnsFreeRecordList);
    registry.erase(operationId);  // releases registry ownership on the sync path
}

The callback receives the user pointer from pQueryContext and the result structure. Its declaration must match the Windows callback calling convention, including WINAPI on 32-bit x86:

VOID WINAPI QueryCompleteCallback(PVOID queryContext,
                                  PDNS_QUERY_RESULT queryResults);

The body should perform bounded work: validate the context identity, capture QueryStatus, decode or copy the records it needs, free the record list, and hand a compact completion object to the component that owns policy. Avoid doing blocking UI work or running arbitrary application callbacks while holding a resolver lock. Microsoft’s sample uses this WINAPI signature; callback notification occurs within the same process context, but that does not mean the initiating stack owns the callback’s memory or that the callback runs on a particular UI thread. (DNS_QUERY_COMPLETION_ROUTINE, Microsoft DNS async query sample at the reviewed revision)

There is also no guarantee that “callback configured” means “pending.” The documented synchronous cases matter when writing tests and cleanup. Exercise an ordinary external name, the local machine’s A/AAAA record, an address query, invalid input, and an immediate DNS error. For every call, verify which branch owns the records and confirm that the callback count matches only genuinely pending requests.

Cancellation requests work; it does not join the callback

DnsCancelQuery() accepts a DNS_QUERY_CANCEL returned for a prior asynchronous query. It does not wait for the query to complete before cancelling. The callback can therefore race with the caller’s cancellation path. Microsoft states that the cancellation handle remains valid until the callback is invoked and DnsCancelQuery() completes, and advises tracking pending queries through their completion callbacks. Do not free the result, cancellation structure, request context, or callback context merely because cancellation returned successfully. (DnsCancelQuery, DNS_QUERY_CANCEL)

A practical owner can maintain a monotonically increasing query ID plus pending, cancel-requested, and completed state under a mutex or another synchronization primitive. Under that lock, allow only one cancellation caller to transition a pending query to cancel-requested; later callers must not reuse the cancel handle. The winning caller first acquires a strong reference to the operation context and marks a cancellation call in flight under the same synchronization used by the callback’s completion transition. It then releases the lock before calling DNSAPI. The cancellation thread’s local strong reference keeps DNS_QUERY_CANCEL and the other context members alive until DnsCancelQuery() returns, even if the callback runs in the meantime and removes the registry entry. Do not retire the registry entry until callback processing is complete and the single in-flight cancellation call, if any, has returned. This lifetime protocol protects storage across either event ordering; it does not promise that cancellation will beat a query that has already completed. Do not hold an application lock across the API call.

If completion wins before the cancellation caller claims the pending state, skip cancellation. The public documentation gives the handle’s lifetime rule but does not specify the result of entering DnsCancelQuery() after the completion callback has already run. Microsoft’s sample demonstrates a timeout-driven cancellation request followed by waiting for the callback, but does not describe that exact race. A single-shot state transition and strong reference prevent duplicate use and premature destruction; they do not make the callback and API entry atomic. Treat cancellation as best-effort, inspect its return status separately, and keep the callback’s QueryStatus authoritative. If correctness depends on cancellation winning or on proving that no call can begin after completion, do not infer that guarantee from the documented lifetime clause; test the exact supported Windows implementation and design the operation so completion remains correct without cancellation.

The callback owns the completion transition, but retirement is a two-party condition: callback processing must be complete, and the cancellation call (if one is in flight) must have returned. A cancellation path therefore records its in-flight lease before dropping the lock, invokes DnsCancelQuery() outside the lock, and records the return before releasing that lease. The callback records its completion under the same lock. Whichever side observes both conditions may retire the registry’s reference; each side still holds its own strong reference until its work is finished. If a new query supersedes an old one, compare the callback’s query ID with the currently selected ID before updating the user-visible result; an old but valid completion must not overwrite newer state.

Do not wait synchronously for a callback from inside the callback itself, and do not make a cancellation handle reusable by zeroing or replacing it while the old operation might still reference it. Where a component can be destroyed while requests are pending, give the operation context an independent lifetime, such as reference-counted ownership, and detach the owner from UI state without invalidating the callback’s memory.

Make record ownership and status visible in diagnostics

Log the query ID, normalized name, RR type, immediate DnsQueryEx() status, callback status when present, elapsed duration, and cancellation outcome. Avoid dumping credentials, complete environment state, or unrelated user data. Distinguish a no-record status, a DNS response code, parameter initialization errors, and transport or resolver failures; one generic “lookup failed” metric hides whether the problem belongs to the caller, the network, or the name.

Record-list memory is an API resource: use DnsRecordListFree() with the documented DnsFreeRecordList mode after processing in both sync and callback paths. Test the case where the owner disappears before completion, a query is superseded, cancellation is requested just before completion, and callback parsing fails midway. Run the harness under Application Verifier and address/thread sanitizers where the toolchain supports them, but still test against the actual Windows SDK and DNSAPI implementation used in production.

DnsQueryEx() becomes predictable when the return status controls the branch and the operation context outlives every callback-visible pointer. Keep its asynchronous cancellation model separate from higher-level policy: the API completes or reports a query, while your application decides whether that result is still relevant. For operator-facing resolver troubleshooting, see Fixing DNS Resolution Failures on Windows and Windows NRPT Split-DNS: Inspect Effective Name Resolution Policy.

Related:

Sources:

Comments