ADR 0048 — DNS resolution for tasks
- Status: Accepted by the owner, 2026-11-02 ("Yes, I accept both ADRs with your recommendations"): every question of §5 as recommended. Asked for by the owner for CeeSafe.Web (
~/dev/CeeSafe.Web/docs/ceesafe-upstream-work.md, item U5, phase A). CeeSafe.Web's HTTP client (its M5) connects to host names. Phase B, a resolver over UDP, is CeeSafe.Web's own work later, on U6. - Scope: lib/std (
AsyncDns), the hosted runtime (a resolver on worker threads), the interpreter's model. - Amended by ADR 0060 (2026-11-03): "no such name" and "no address" are
IoError.HostNotFound, notNotFound, and the interpreter's model answers it for names under.invalid.
1. Context
AsyncTcpStream.connect takes an address, not a name. A task that has a name has nowhere to turn it into addresses.
The system's resolver is getaddrinfo, which blocks and cannot be cancelled. Async.blocking runs blocking work on a worker thread, but a cancelled task still waits for its worker (§40.4). A lookup that hangs for 30 seconds would hold a cancelled request, and a deadline (Async.timeout) could not cut it short. A lookup needs its own path: the task can leave, and the worker finishes alone.
2. Options
How errors are reported.
A. IoError kinds that exist (proposed) | B. A new DnsError | |
|---|---|---|
| What | AsyncError.Io(NotFound) for an unknown name, TimedOut for a temporary failure, and so on | Result<…, DnsError>, with its own variants |
| Cost | the resolver's exact code is lost beyond those kinds | a second error type beside AsyncError: every caller converts, since ? does not |
A is proposed. A caller wants to know "no such name" and "try again", and both are there. IoError gains nothing, so no match breaks.
3. Proposed decision
3.1 The operation (lib/std, net.cee)
public struct AsyncDns {
public async fn resolve(host: StringView, port: u16) -> Result<Vector<SocketAddress>, AsyncError>;
}Result. The addresses of both families, each with
port, in the resolver's order, duplicates removed. The system's settings (/etc/hosts,gai.conf, Windows' prefix policy) decide that order. It is never empty: no addresses isNotFound.Literals. A host that is an IPv4 or IPv6 literal (
SocketAddresses.parseIp, zones included) is returned at once as its one address, with no thread.Calls.
- POSIX:
getaddrinfo(host, NULL, {AF_UNSPEC, SOCK_STREAM, flags 0}).AI_ADDRCONFIGis off, solocalhostresolves on a host without IPv6. - Windows:
GetAddrInfoW, the name converted from UTF-8 to UTF-16. - A name is passed as it is given: an internationalised name must already be in its ASCII form (punycode).
- POSIX:
Errors (
AsyncError.Io(kind)):Resolver result Kind EAI_NONAME,EAI_NODATA(WSAHOST_NOT_FOUND,WSANO_DATA), or no addressesNotFoundEAI_AGAIN(WSATRY_AGAIN)TimedOut(temporary: try again)EAI_MEMORYOutOfMemoryEAI_FAMILY,EAI_SERVICE,EAI_BADFLAGS; a name that is empty, has a NUL, or is longer than 253 bytesInvalidInputEAI_SYSTEMthe kind of its errnoanything else ( EAI_FAIL,WSANO_RECOVERY)Other(EIO)Profiles. CosmoOS userland has no resolver yet: its lookups of a name return
Unsupported(literals still work). The operation is hosted only, like the sockets.
3.2 Workers and cancellation
- The pool. Lookups run on at most 4 resolver threads per process, started when first needed and kept until it exits. Further lookups wait in first-come order. A flood of lookups costs a queue entry each, not a thread each.
- Waiting. The task parks until its lookup is done; the worker wakes it through its executor, as
Async.blocking's worker does. - Cancellation.
- A cancelled task returns
Err(Cancelled)at once, whether its lookup is queued (taken out) or running (abandoned). - The lookup's record is allocated by the runtime, not in the task's frame. An abandoned one is freed by its worker when
getaddrinforeturns, so nothing refers to the frame after the task leaves. - So
Async.timeout(2000, …, async () => await AsyncDns.resolve(…))returns after 2 seconds, whatever the resolver does.
- A cancelled task returns
- Allocation.
- If the record cannot be allocated:
OutOfMemory. - If no thread can start: the lookup runs on the calling thread, as
Async.blockingdoes when it cannot start one. The result is the same, but it is not cancellable then.
- If the record cannot be allocated:
3.3 The runtime (rt.h)
void *ceesafe_dns_start(const uint8_t *host, uint64_t length, uint16_t port); /* NULL: out of memory */
int32_t ceesafe_dns_poll(void *lookup); /* 0 not done (parks), 1 done, else a negated errno (its kind) */
uint32_t ceesafe_dns_count(void *lookup);
void ceesafe_dns_address(void *lookup, uint32_t index, uint8_t *bytes, uint32_t *family, uint32_t *scope);
void ceesafe_dns_end(void *lookup); /* done: frees it; still queued: unqueued and freed; running: abandoned */lib/std reads the addresses into a Vector<SocketAddress>, then ends the lookup, in a drop too, so every path frees it once.
3.4 The interpreter
The interpreter runs on stage 0's library (0.3.2), which has no resolver. Its model therefore resolves:
- literals, as natively;
localhost, as127.0.0.1then::1.
Any other name is Err(Io(Unsupported)). A test can rely only on literals and localhost, and gets the same answer everywhere: the interpreter's localhost order is fixed, so tests should not depend on it natively.
4. Consequences
- CeeSafe.Web's client resolves, connects to the addresses in order, and can bound the whole lookup with a deadline.
- A cancelled lookup's thread stays busy until the resolver returns. With 4 threads, at most 4 hung lookups can delay the queue. That is the price of
getaddrinfo, until phase B. - New diagnostics: none.
5. Questions for the owner
- Report errors as existing
IoErrorkinds throughAsyncError.Io(option A), not a newDnsError? Recommended: yes. - At most 4 resolver threads per process, queueing beyond? Recommended: yes.
- A cancelled lookup returns at once and is abandoned to its worker, which frees it? Recommended: yes.
- The interpreter resolves literals and
localhostonly, other namesUnsupported? Recommended: yes. - Release U8 (ADR 0047) and U5 together as 0.8.0? Recommended: yes. They add to the manifest and the library, so a minor version, not a patch.
6. Implementation plan (once accepted)
- Spec: §40.4 (
AsyncDns), c-backend.md §6 (the resolver), the API page. - Runtime: the lookup record, the pool and its queue, POSIX and Windows calls, the error mapping; then
tools/gen_runtime.py. - lib/std:
AsyncDns.resolve, a record type with adrop; thentools/gen_library.py. - The interpreter's model (§3.4).
- Tests:
- corpus run-pass, interpreter and native:
- literals, IPv4 and IPv6 with a zone, each its one address;
localhost, compared as a set: it contains127.0.0.1or::1;- an invalid name (
NotFound; the reserved.invalidtop-level domain, RFC 6761); - an empty or over-long name (
InvalidInput);
- native only:
- a lookup cancelled while running, under a resolver made slow by a test hook (
CEESAFE_DNS_DELAY_MS); ASan shows the abandoned record freed withlive=0; - 100 lookups at once, which never use more than 4 threads;
- a lookup cancelled while running, under a resolver made slow by a test hook (
tools/cancel_sweep.pyover the new tests; every reactor; Windows on the Windows 11 ARM64 test host.
- corpus run-pass, interpreter and native:
- The guide (
guide/async.md, sockets), ROADMAP §10, the 0.8.0 release.
7. Implementation notes (2026-11-02)
- Threads. A resolver thread ends when the queue is empty, rather than staying until the process exits: the next lookup starts one again. Threads, records and the lock are the platform's (detached threads,
SRWLOCKor a staticpthread_mutex_t,ceesafe_platform_alloc). So a lookup a cancelled task left behind is not the program's memory:CEESAFE_ALLOC_REPORTshowslive=0, and the process does not wait for the resolver to exit. - Where it is. The resolver is in
ceesafe_rt_async.c. A first cut put it inceesafe_rt_io.c, which every hosted program links, calling into the async runtime, which only async programs link. On Windows a program without async then failed to link (the package tests found it). - Waking. A task waits as a channel's waiter does: cancellable, woken from another thread through its executor. The waiter is registered and unregistered under the lock, so no thread wakes a task after it has ended its lookup.
- Tests.
- The corpus case (run-pass/async-dns) runs in the interpreter and natively with one expected output. An unknown name is
NotFoundnatively andUnsupportedin the interpreter; both print "not resolved". - The timed and sanitizer checks are
tools/dns_check.py(in the contract suite). The thread bound is checked by time: eight lookups with a 300 ms resolver take two rounds.
- The corpus case (run-pass/async-dns) runs in the interpreter and natively with one expected output. An unknown name is