# resolvd

> The stub resolver as built: how resolvd takes its scopes from netd and its fallbacks from the registry, routes, expands, caches and forwards every question, and answers it at the native socket, the 127.0.0.53 stub listener and the NSS shim.

---

# 1.1 Overview

_Peios / Peios technical reference / resolvd / Introduction_

> resolvd is the one process on a Peios machine that answers names — three doors in front of one engine — and the handful of decisions that would not be guessed from its name.

**resolvd** is the Peios stub resolver. It is the one process on a
machine that answers names: a DNS client that answers from what it knows
about the machine, from its cache, or by forwarding to an upstream
server. It is not a recursive resolver and not an authoritative server.

The contract it implements is the Name Resolution Interface, PSPU §6.
This manual describes resolvd 0.1.5 as built: the order it does things
in, the limits it applies, and what it does where the contract leaves a
choice.

## 1.1.1 Three doors, one engine

A question reaches resolvd by one of three doors (PSPU §6.3):

| Door | Where | Who uses it |
|---|---|---|
| Native socket | `/run/resolvd/resolv.sock` | The `resolv` command, the NSS shim, Peios tools |
| Stub listener | `127.0.0.53:53`, UDP and TCP | Programs that speak DNS themselves |
| NSS shim | `libnss_peios_net.so.2`, loaded into every glibc process | `getaddrinfo`, `gethostbyname` and friends |

The shim is not a separate resolver: it is a client of the native
socket. The native socket and the stub listener both hand their
questions to the same engine inside the daemon, and the engine is the
only thing that decides an answer.

## 1.1.2 What is surprising about it

**It owns no network configuration.** The servers, search domains and
addresses of each interface arrive from netd over netd's control socket,
as a whole snapshot each time anything changes. The registry holds only
the fallbacks — servers to use when no interface offers any, extra
search domains — and the static names that replace `/etc/hosts`. resolvd
never writes `/etc/resolv.conf`; the file it ships is a constant that
points at the stub listener.

**A question goes to one interface.** Each interface's contribution is
a *scope*, and every name is routed to exactly one of them. Nothing is
ever asked of every interface's servers at once. A single-label name is
never sent upstream bare, and the cache is partitioned by scope, so an
answer learned through a VPN disappears when the VPN's servers change or
the VPN goes away.

**One thread, and an engine with no I/O.** The daemon is a single
`poll` loop. The engine inside it is a pure state machine: it takes
questions, upstream replies and the passage of time, and returns actions
— bytes to send, transactions to abandon, answers to deliver. Every
socket is the loop's. Everything is therefore serialised, which is why a
reply that cannot be written promptly holds up every other client
(§9.4).

**It holds no privilege.** resolvd runs under its own virtual service
account. Port 53 is reached through a port reservation granted to its
service SID, not through a capability.

**It does not wait for the network.** resolvd reports itself ready as
soon as its two listening doors are open. `localhost`, the machine's own
name and the registry's static names are answered with no network and no
netd at all.

**It does not validate.** No DNSSEC validation is performed, and every
answer from every door is reported `unvalidated`.

---

# 1.2 What This Manual Covers

_Peios / Peios technical reference / resolvd / Introduction_

> The daemon, the operator command and the NSS shim as built, and where the contract, netd's side of the channel and the kernel mechanisms are described instead.

This manual covers the resolvd source tree as of release 0.1.5:

| Component | Package | Installed as |
|---|---|---|
| The daemon | `dev.peios.resolvd` | `/usr/sbin/resolvd` [*coverage.daemon-installed-path] |
| The operator command | `dev.peios.resolv` | `/usr/bin/resolv` [*coverage.resolv-installed-path] |
| The NSS shim | `dev.peios.resolvd-nss` | `/usr/lib/x86_64-linux-peios/libnss_peios_net.so.2` [*coverage.shim-installed-path] |

## 1.2.1 Other packaged files [*coverage.packaged-files]

`dev.peios.resolvd` also ships the constant `/usr/etc/resolv.conf`, the
service definition and port reservation seeds under `/usr/share/regim/`,
the registry reference `/usr/share/regman/resolvd.regman`, and the
`resolvd(8)` page; `dev.peios.resolv` ships `resolv(1)`.

Each package has a matching `-debuginfo` package, and
`dev.peios.resolvd-debugsource` carries the sources for all three.

## 1.2.2 The source tree

The source is one workspace of five crates, and the split is visible in
what each part of the system can do:

| Crate | What it is |
|---|---|
| `dns` | The DNS wire codec: names, messages, records. No I/O and no policy. |
| `libresolv` | The native channel's types, MessagePack codec and framing. No I/O beyond framing. |
| `resolvd` | The engine, the cache, the stub door's DNS rendering, and the daemon around them. |
| `resolv` | The operator command. |
| `nss` | The shim. Built on `libresolv` and libc, without libpeios (§7.1). |

## 1.2.3 Covered elsewhere

**The contract.** What any resolver standing in resolvd's place has to
do is PSPU §6: the doors (PSPU §6.3), the native channel's framing and
control object (PSPU §6.4), the requests (PSPU §6.5), outcomes and
validation (PSPU §6.6), the resolution model (PSPU §6.7), the stub door
(PSPU §6.8), the network manager channel (PSPU §6.9), the shim
(PSPU §6.10), and the limits (PSPU §6.B). Where
a chapter here touches the contract it cites it and adds only what
resolvd does.

**netd's side of the channel.** Which interfaces appear in a snapshot,
how their servers and domains are merged from profile and lease, and
when a snapshot is sent, are netd's, and are described in the netd TRM.
This manual covers what resolvd does with a snapshot once it has one.

**Kernel mechanisms.** Registry watches, access checks, file
descriptors and port reservations are described in the Peios Kernel
TRM. This manual says which of them resolvd uses and how.

---

# 1.3 Terminology

_Peios / Peios technical reference / resolvd / Introduction_

> The terms specific to resolvd's engine — task, candidate, transaction, attempt, routable scope — and where the contract's own terms are defined.

The protocol's terms — name, question, record, scope, route, outcome,
source, door, synthetic answer, control object, stub door — are used as
PSPU §6.2 defines them. The terms below are resolvd's own.

**Task.** One question the engine is working on: a name and a record
type. A `resolve` or `reverse` request, and every stub query, is one
task. A `lookup` is one task per address family it asks for.

**Candidate.** One name a task asks. A multi-label name has exactly one
candidate, itself. A single-label name has one candidate per applicable
search domain (§4.3), asked in turn.

**Transaction.** One query sent to one server over one transport, with
its own DNS message identifier, its own case pattern, its own socket and
its own deadline.

**Attempt.** A transaction counted against a candidate's attempt limit.
Every UDP transaction is an attempt; the TCP transaction that follows a
truncated reply is not (§4.6).

**Routable scope.** In [source `b4f7085`](https://github.com/peios/resolvd/blob/b4f70857729069952762f8e2a2b56357b15d4760/resolvd/src/engine.rs),
a scope that is up — at level `link` or better, in
the sense of PSPU §6.2 — and has at least one server. Only routable
scopes take part in routing and contribute search domains (§4.3, §4.4);
an up scope with no servers takes part in neither.

The [proposed source correction](https://github.com/peios/resolvd/blob/f5190f8bd47ae74da73e0c1ffa405f86eb782acf/resolvd/src/engine.rs) separates
being up from having DNS servers, as PSPU §6.2 requires. All up scopes
participate in search expansion and routing steps 1–4, with step 1 still
requiring `addressed` or better and the exclusive flag. Only the ordinary
lowest-metric choice in step 5 requires servers (§4.4). This is proposed
source behavior, not a released package or an installed resolver; check
the installed source revision before relying on it.

**Scope key.** The string that identifies a scope in the cache and in
routing: the interface's `ifid` from netd, or `fallback` for the
registry's fallback servers.

**Demoted server.** A server address whose last failure was less than
30 seconds ago, and which has not given a usable reply since; it is
tried after its healthy peers (§4.6).

**In-flight ceiling.** The bound on outstanding upstream transactions,
beyond which a question that needs the network is answered `unavailable`
at once (§4.6).

**The loop.** resolvd's single thread and its `poll` (§2.5).

---

# 2.1 The Service

_Peios / Peios technical reference / resolvd / Startup And Configuration_

> How peinit runs resolvd — the service definition, the identity it runs under, the port reservation that lets it bind port 53, readiness, restart, and where its log lines go.

## 2.1.1 The service definition seed [*service.definition-seed-inert-until-applied]

resolvd is started by peinit from `Machine\System\Services\resolvd`. The
package ships the definition as a registry seed,
`/usr/share/regim/resolvd-service.reg`, which is inert until an image
names it in its `[registry] autoapply` list.

## 2.1.2 The service definition values

| Value | Type | Data | Effect |
|---|---|---|---|
| `ImagePath` | `REG_SZ` | `/usr/sbin/resolvd` | The binary. resolvd reads no command-line arguments. [*service.image-path-no-arguments] |
| `Triggers` | `REG_MULTI_SZ` | `boot` | Started at boot. [*service.triggered-at-boot] |
| `Identity` | `REG_SZ` | `Service` | Runs under its own virtual service account. [*service.identity-is-own-virtual-account] |
| `Readiness` | `REG_DWORD` | `0` | Notify: ready when resolvd says so. [*service.readiness-is-notify] |
| `RuntimeDirectories` | `REG_MULTI_SZ` | `resolvd` | peinit creates `/run/resolvd`. [*service.runtime-directory] |
| `RestartPolicy` | `REG_DWORD` | `2` | Always restarted. [*service.restart-policy-always] |
| `LimitNOFILE` | `REG_DWORD` | `16384` | Descriptor headroom for 4 096 upstream transactions, their client waiters, 512 pending clients and daemon descriptors. |
| `ErrorControl` | `REG_DWORD` | `0` | |
| `DisplayName` | `REG_SZ` | `Stub resolver` | |
| `Description` | `REG_SZ` | (one sentence) | |

## 2.1.3 Identity and privilege

resolvd holds no privilege and needs none. Its one claim on the machine
that an ordinary process could not make — binding UDP and TCP port 53 —
is met by a port reservation rather than a capability (PSPU §6.8).

Without the reservation applied, the stub listener cannot bind and
resolvd exits at startup (§2.2, §9.3).

### 2.1.3.1 The port reservation seed [*service.port-reservation-seed]

The reservation is a second seed, `/usr/share/regim/resolvd-port.reg`,
also inert until an image applies it. It writes one value:

| Key | Value | Type |
|---|---|---|
| `Machine\System\Network\TcpIp\PortReservations` | `tcp,udp:53` | `REG_BINARY` |

### 2.1.3.2 Who holds the reservation [*service.reservation-granted-to-system-and-service-sid]

The data is a security descriptor granting the reservation to SYSTEM and
to resolvd's service SID,
`S-1-5-80-3864064249-1823296737-2008945602-1354971773-2894779966`, so it
follows the service rather than whichever account the service happens to
run as.

The selector `tcp,udp:53` is more specific than the kernel package's
`tcp,udp:1-1023` and so decides port 53 alone. The descriptor bytes are
generated from the SID by a tool in the source tree, not written by
hand.

## 2.1.4 Readiness

With `Readiness` 0 peinit waits for a notification. resolvd:

- sends the datagram `READY=1` to the socket named by the
  `NOTIFY_SOCKET` environment variable once the native socket and both
  stub sockets are open and the registry has been read; [*service.ready-after-doors-open]
- does not wait for netd: readiness is sent after the first attempt to
  reach netd whether that attempt succeeded or not; [*service.ready-does-not-wait-for-netd]
- sends nothing when `NOTIFY_SOCKET` is unset, and logs a failure to
  send as `readiness notify: <error>` and carries on starting. [*service.notify-failure-not-fatal]

## 2.1.5 Restart

`RestartPolicy` 2 restarts resolvd whenever it exits. A restarted
resolvd removes the socket file its predecessor left, logs
`removed a stale /run/resolvd/resolv.sock`, and binds a fresh one
(§2.4). Everything resolvd holds is in memory, so a restarted resolvd
has:

- an empty cache; [*service.restart-empties-cache]
- no demoted servers; [*service.restart-clears-demotions]
- every counter at zero (§4.10);
- no scopes, and the kernel's hostname, until the first snapshot after
  netd is reached again (§3.3). [*service.restart-starts-without-scopes]

## 2.1.6 Log lines

### 2.1.6.1 The log line format [*service.log-line-format]

Every line resolvd logs has the form

```text
resolvd: <level>: <text>
```

where the level is `info`, `warn` or `error`. Informational lines go to
standard output; warnings and errors go to standard error. peinit forwards
both streams to eventd, marking only standard error as `is_error=true`.
The lines are read with:

```sh
evctl 'LOGS FROM resolvd SINCE 1h ago TAKE 40'
```

### 2.1.6.2 The kernel log mirror [*service.kmsg-mirror-fails-once]

Each line is also offered to `/dev/kmsg`, at kernel priority 6, 4 or 3
for info, warn and error. The mirror is opened when the first line is
logged, after that line has been written to its output stream, and the open
is never retried.

`/dev/kmsg` is writable by SYSTEM only, so for resolvd the open fails,
and resolvd writes one line to standard error saying so, directly after
the first log line:

```text
resolvd: warn: /dev/kmsg mirror unavailable (<error>): log lines go to stdout/stderr only
```

The mirror warning follows the first log call. That first line may be on
stdout or stderr, so readers must not assume a cross-stream ordering.
On a start that fails, the first logged line is the fatal one, such as
`native socket: <error>` (§2.4).
On a start that succeeds, it is a warning from earlier in startup when
there is one, such as `removed a stale /run/resolvd/resolv.sock` (§2.4)
or a `Dns …` warning (§2.3), and otherwise the result of
the first attempt to reach netd, `subscribed to netd` or
`netd not reachable (<error>); retrying`, which comes before
`listening on …` (§2.2). The mirror line is written once, and nothing
further about the mirror is written. resolvd's lines never reach the console or
`dmesg`.

---

# 2.2 Startup Sequence

_Peios / Peios technical reference / resolvd / Startup And Configuration_

> What resolvd does between exec and READY=1, in order, which steps are fatal, and the only ways the process exits.

## 2.2.1 The sequence

1. **The native socket.** The runtime directory and the socket are
   created and given their modes and descriptors (§2.4). A failure to
   create the directory, set a mode, remove a stale socket file, bind,
   or make the listener nonblocking is fatal: resolvd logs `native socket: <error>` at error level and
   exits with status 1. A failure to write a descriptor is not fatal
   (§2.4). [*startup.native-socket-failure-is-fatal]
2. **The stub listener.** A UDP socket and then a TCP listener are bound
   to `127.0.0.53:53`. A failure of either is fatal: resolvd logs
   `stub listener on 127.0.0.53:53: <error>` and exits with status 1. [*startup.stub-listener-failure-is-fatal]
3. **The registry.** `Machine\System\Network\Dns` is read whole (§2.3).
   A missing or unreadable key is not an error; every value takes its
   default. [*startup.missing-dns-key-is-not-an-error]
4. **The registry watch.** A subtree watch is armed on
   `Machine\System\Network` (§2.3). If it cannot be armed, resolvd logs
   `registry watch unavailable (<error>); configuration is read once`
   and runs on the configuration it read in step 3 until it restarts. [*startup.watch-failure-reads-configuration-once]
5. **Query entropy.** The engine is given a kernel-randomness provider.
   Every upstream transaction obtains fresh bytes through nonblocking
   `getrandom`; startup does not seed a userspace generator or read
   `/dev/urandom`. There is no clock fallback. [*startup.entropy-from-kernel-per-query]
6. **The engine's inputs.** The engine is given the kernel's hostname,
   the fallback servers and search domains, and the static names. A
   kernel hostname that is empty or `(none)` counts as no hostname. [*startup.kernel-hostname-none-is-no-hostname]
7. **The control object** is built from `ControlSecurity` (§5.2).
8. **netd.** The first attempt to subscribe is made immediately (§3.1).
   Its failure is not fatal and is retried with backoff (§3.2). [*startup.first-netd-attempt-immediate-and-not-fatal]
9. **The listening line.** resolvd logs
   `listening on /run/resolvd/resolv.sock and 127.0.0.53:53` at info
   level. [*startup.listening-log-line]
10. **Readiness.** `READY=1` is sent (§2.1).
11. **The loop** is entered (§2.5).

## 2.2.2 Answering from the moment of readiness [*startup.answers-before-first-snapshot]

Both doors are open, and answering, before readiness is reported, and
neither waits for netd. A question asked the moment resolvd is ready is
answered at once: synthetic and static names are answered, and anything
that needs a server is answered `unavailable` until either a snapshot
arrives or fallback servers are configured (§3.2).

## 2.2.3 Exiting

resolvd has no shutdown path of its own. Apart from the startup failures
above, nothing a client or an upstream server sends makes it exit.

### 2.2.3.1 Ending on a signal [*startup.no-signal-handlers-socket-left-behind]

resolvd installs no signal handlers, so it ends when peinit signals it,
with the default disposition, and leaves `/run/resolvd/resolv.sock`
behind. The next start removes the stale socket file before binding
(§2.4).

### 2.2.3.2 A poll failure exits [*startup.poll-failure-exits]

When `poll` fails with anything other than `EINTR`, resolvd logs
`poll: <error>` at error level and exits with status 1. `EINTR` is
retried.

---

# 2.3 Registry Configuration

_Peios / Peios technical reference / resolvd / Startup And Configuration_

> The values resolvd reads from Machine\System\Network\Dns, how each is parsed and what a malformed one does, and how a change reaches the running daemon.

The registry holds resolvd's fallbacks and its static names, under
`Machine\System\Network\Dns`; which keys a resolver reads is fixed by
PSPU §6.9. Nothing about the live network is read from the registry;
that arrives from netd (§3).

## 2.3.1 The `Dns` key and its defaults [*config.absent-key-gives-defaults]

The key is opened for query and enumerate-subkey access. When it does
not exist or cannot be opened, every value below takes its default.

| Value | Type read | Default | Used for |
|---|---|---|---|
| `FallbackServers` | `REG_MULTI_SZ`, or one `REG_SZ` / `REG_EXPAND_SZ` | none | The fallback scope's servers (§4.4) |
| `ExtraSearchDomains` | `REG_MULTI_SZ`, or one `REG_SZ` / `REG_EXPAND_SZ` | none | Search domains after every interface's (§4.3) |
| `ControlSecurity` | `REG_BINARY` | the compiled default | The control object (§5.2) |
| `Hosts\` | a subkey | empty | Static names (§4.2) |

## 2.3.2 Reading strings

- A `REG_SZ` or `REG_EXPAND_SZ` is one string: its data up to the first
  NUL, read as UTF-8. When those bytes are not UTF-8, the value is
  treated as absent, with no log line. [*config.single-string-read-to-first-nul]
- A `REG_EXPAND_SZ` is read as it is stored; nothing in it is expanded. [*config.expand-sz-not-expanded]
- A `REG_MULTI_SZ` is split at every NUL. Empty strings are skipped, and
  a string that is not UTF-8 is skipped silently, with no log line,
  while the other strings are used. [*config.multi-sz-empty-and-non-utf8-strings-skipped]
- A value of any other type is treated as absent. [*config.other-value-types-treated-as-absent]

## 2.3.3 `FallbackServers`

- Each string is trimmed of surrounding whitespace and parsed as an IPv4
  or IPv6 address. A string that does not parse is skipped and logged as
  `Dns FallbackServers: ignoring malformed address "<string>"`, with the
  string as stored — untrimmed, in double quotes, with quotes,
  backslashes and control characters escaped. A lone `REG_SZ` that is
  empty or only whitespace, a whitespace-only string in a multi-string,
  and an address with a zone such as `fe80::1%eth0` (§4.7) are all
  logged this way. [*config.fallback-malformed-address-skipped-and-logged]
- There is no way to give a port; every server is asked on port 53. [*config.fallback-servers-port-53]

The order of the list is the order servers are tried in (§4.6).

## 2.3.4 `ExtraSearchDomains` [*config.extra-search-domain-malformed-skipped-and-logged]

Each string is parsed as a domain name, with or without a trailing dot.
It is not trimmed, so a leading or trailing space becomes part of a
label. A string that does not parse, or that is the root — a lone empty
`REG_SZ`, or `.` — is skipped and logged as
`Dns ExtraSearchDomains: ignoring malformed domain "<string>"`, quoted
as for `FallbackServers`.

## 2.3.5 `ControlSecurity`

- A `REG_BINARY` value with at least one byte is taken as a
  self-relative security descriptor. An empty value, a value of another
  type, or an absent value selects the compiled default, with no log
  line. [*config.control-security-empty-or-wrong-type-selects-default]
- Bytes that do not form a valid descriptor also select the compiled
  default, and resolvd logs
  `ControlSecurity is not a valid descriptor (<error>); using the default`
  each time it builds the control object from them: at startup, and at
  every configuration change (below). [*config.control-security-invalid-selects-default-and-logs]

## 2.3.6 `Hosts\`

- Every value under `Machine\System\Network\Dns\Hosts` is one static
  name. The value's name is the host name and its data is one address
  (`REG_SZ` or `REG_EXPAND_SZ`) or several (`REG_MULTI_SZ`), read as
  above. [*config.hosts-value-is-one-static-name]
- A value name that is not UTF-8 is skipped silently. The registry
  refuses such a name when the value is written, so the check is
  defensive. [*config.hosts-non-utf8-name-skipped]
- A value name that does not parse as a domain name is skipped and
  logged as `Dns Hosts: ignoring malformed name "<name>"`. [*config.hosts-malformed-name-skipped-and-logged]
- A value name that parses to the root — an empty name, or `.` — is
  skipped silently. [*config.hosts-root-name-skipped-silently]
- Each address is parsed as for `FallbackServers`, and a malformed one
  is skipped and logged as
  `Dns Hosts: ignoring malformed address "<string>"`. [*config.hosts-malformed-address-skipped-and-logged]
- A name left with no valid address, including one whose value has any
  other type, is not a static name at all. [*config.hosts-name-without-address-dropped]
- Names match case-insensitively, so `Printer` and `printer` are the
  same static name. [*config.hosts-names-case-insensitive]

Where the same address is listed under several names, the reverse lookup
of that address returns one of them (§4.2). A static name named
`localhost`, or under it, never answers a forward question (§4.2).

## 2.3.7 Changes at run time

### 2.3.7.1 The watch on the network key [*config.watches-network-key-recursively]

resolvd watches `Machine\System\Network`, recursively and for every kind
of change, rather than the `Dns` key itself: the `Dns` key need not
exist at boot, and a watch on a missing key cannot see it appear. Watch
events are read into a 16 384-byte buffer.

### 2.3.7.2 What triggers a re-read [*config.any-event-rereads-dns-key]

Every non-empty batch of watch events, from anywhere under
`Machine\System\Network`, causes the whole `Dns` key to be read again,
as at startup. netd's own writes under that key cause such re-reads too.
Each re-read logs the `Dns FallbackServers`, `Dns ExtraSearchDomains`
and `Dns Hosts` warnings above again for every malformed string still
present, whether or not anything has changed.

### 2.3.7.3 An unchanged configuration [*config.unchanged-configuration-is-a-no-op]

The fresh configuration is compared with the one in use. When nothing
differs, nothing is applied and nothing more is logged.

### 2.3.7.4 A changed configuration

When anything differs, resolvd, in this order:

1. rebuilds the control object from `ControlSecurity`; [*config.change-rebuilds-control-object]
2. replaces the fallback servers and search domains, discarding every
   answer cached through the fallback scope if the server list differs
   in content or in order; [*config.fallback-server-change-flushes-fallback-cache]
3. replaces the static names; [*config.change-replaces-static-names]
4. logs `configuration changed` at info level. [*config.change-logged]

### 2.3.7.5 When a change takes effect

- Every change applies to the questions and requests that arrive after
  it; nothing needs a restart. [*config.changes-apply-live]
- Static names are never cached, so a new or removed `Hosts\` value is
  visible to the next question. [*config.static-name-change-visible-at-once]
- Answers already cached through an interface scope are not discarded
  by a registry change. [*config.registry-change-keeps-interface-cache]

### 2.3.7.6 When the watch fails

- If reading watch events fails, resolvd logs
  `registry watch: <error>; re-arming` and arms a new watch. The key is
  not read again when the new watch is armed, so a change made in
  between is applied only with the next event. [*config.watch-error-re-arms]
- If arming the new watch also fails, watching stops without a further
  message, and the configuration in use stays as it is until resolvd
  restarts. [*config.failed-re-arm-stops-watching-silently]

---

# 2.4 Sockets and Descriptors

_Peios / Peios technical reference / resolvd / Startup And Configuration_

> The native socket and its directory and their descriptors, the stub listener's sockets, and every other descriptor resolvd holds.

## 2.4.1 The native socket

The native door is a `SOCK_STREAM` Unix socket at
`/run/resolvd/resolv.sock` (PSPU §6.4), inside resolvd's runtime
directory rather than directly in `/run`, which resolvd's account cannot
write.

At startup resolvd:

1. creates `/run/resolvd` if peinit has not, and sets its mode to
   `0755`; [*sockets.runtime-directory-mode-0755]
2. replaces the directory's DACL (below);
3. removes any existing `/run/resolvd/resolv.sock`, logging
   `removed a stale /run/resolvd/resolv.sock` at warn level when there
   was one, and a removal that fails is fatal (§2.2);
4. binds the socket, sets its mode to `0666`, and replaces its DACL in
   the same way; [*sockets.socket-mode-0666]
5. makes the listener nonblocking.

### 2.4.1.1 A socket left by a previous run [*sockets.stale-socket-removed-on-restart]

resolvd leaves its socket behind when it ends (§2.2), so a start that
follows an earlier run finds one unless something else has removed it.
The previous run gave its own account full access to the directory and
the socket (below), so the new process removes the file in step 3, logs
the `removed a stale …` line, and binds a fresh socket. This holds for
every restart of the service: `svctl restart`, a stop and a start, and
a restart by the policy after resolvd exits or is killed.

### 2.4.1.2 The DACL on the directory and socket [*sockets.directory-and-socket-dacl]

The DACL written to both the directory and the socket is:

| Trustee | Access |
|---|---|
| SYSTEM | `GENERIC_ALL` |
| resolvd's own account (its token's user) | `GENERIC_ALL` |
| Everyone | `GENERIC_READ`, `GENERIC_WRITE`, `GENERIC_EXECUTE` |

resolvd's own entry is what lets the next run start. Everyone's read,
write and execute include neither `DELETE` nor deleting a child, so
without it the next start could not remove the stale socket: it would
exit with `native socket: Permission denied (os error 13)`, and the
restart policy would loop on that until the file was removed by hand.
If resolvd cannot read its own identity it logs at warn level

```text
could not read resolvd's own identity (<error>); a restart may not be able to replace <path>
```

and writes the DACL without that entry.

### 2.4.1.3 The owner of the directory and socket [*sockets.owner-left-as-resolvd]

Only the DACL is written, so each object keeps the owner it was created
with. peinit creates `/run/resolvd` before resolvd starts (§2.1), so the
directory is owned by SYSTEM; resolvd creates the socket, which is owned
by resolvd's account. Reaching the socket grants nothing: every request
is checked against the control object (§5.2).

### 2.4.1.4 A descriptor that cannot be written

If the descriptor cannot be written, resolvd logs at error level

```text
could not set a descriptor on <path> (<error>); programs other than SYSTEM and administrators will not be able to reach the socket
```

and carries on. The socket then keeps whatever descriptor it was created
with, and for ordinary processes the native door — and with it the NSS
shim — is unreachable while the stub listener still works (§9.3).

## 2.4.2 The stub listener

- Two sockets, a UDP socket and a TCP listener, are both bound to
  `127.0.0.53` port 53 and both nonblocking (PSPU §6.8). [*sockets.stub-udp-and-tcp-on-127-0-0-53]
- Nothing is bound on `::1` or on any other address. [*sockets.nothing-bound-on-other-addresses]

Binding port 53 depends on the port reservation (§2.1).

## 2.4.3 Other descriptors

| Descriptor | Count | Lifetime |
|---|---|---|
| The registry watch on `Machine\System\Network` | 0 or 1 | From startup; replaced on a watch error (§2.3) |
| The netd channel | 0 or 1 | While subscribed (§3.1) |
| Native connections reading or awaiting resolution | up to 256 total | Reading: at most 5 s (§5.1); resolving: until completion or definite departure (§4.1) |
| Stub TCP connections reading or awaiting resolution | up to 256 total | Reading: at most 10 s (§6.1); resolving: until completion or definite departure (§4.1) |
| Queued native reply writers | up to 256 | Until written, failed, or the one-second write deadline (§2.5) |
| Queued stub TCP reply writers | up to 256 | Until written, failed, or the one-second write deadline (§2.5) |
| Upstream sockets | one per transaction, at most 4 096 including retries | One transaction (§4.7) |

### 2.4.3.1 Connections whose request has been read [*sockets.resolving-connections-keep-their-slot]

A connection whose request has been read keeps occupying a slot in its
door's 256-connection allowance until resolution completes or a definite
hangup/error abandons it. The engine's attempts and timeouts (§4.6) govern
this phase; the request-delivery deadline no longer applies. A completed
answer that cannot be written immediately moves to the separate bounded
reply-writer queue (§2.5).

UDP questions share the one listener socket, but retain at most 256
waiting origins in total and 16 per source address and port (§6.1).

---

# 2.5 The Event Loop

_Peios / Peios technical reference / resolvd / Startup And Configuration_

> resolvd's single thread — what it polls, how long it sleeps, the order it services ready descriptors in, and what that order means for a client.

## 2.5.1 One thread and one poll [*loop.single-thread-single-poll]

resolvd runs on one thread. Every input is a descriptor in one `poll`
call, and the engine sees time only when the loop hands it the current
instant.

## 2.5.2 What is polled

Each iteration builds the poll set afresh:

1. the native listener, except during its accept-error backoff;
2. the stub UDP socket;
3. the stub TCP listener, except during its accept-error backoff;
4. the netd channel, while connected;
5. the registry watch, while armed;
6. every native connection still sending its request;
7. every stub TCP connection still sending its query;
8. every upstream socket — for reading, or for writing while a TCP
   connection is still being established or its query is still being
   sent;
9. native and stub TCP replies waiting to write, for writability;
10. native and stub TCP streams awaiting engine answers, for hangup and
    error indications only. Readability and read-half-close are not
    requested, preserving supported half-closes without spinning on EOF.

### 2.5.2.1 The poll timeout [*loop.poll-timeout-is-earliest-deadline]

The poll timeout is the earliest of: the next upstream transaction
deadline, the next netd reconnection time while disconnected, the
moment the oldest stub TCP connection reaches its 10-second bound, the
moment the oldest native connection reaches its 5-second bound, the
earliest queued reply write deadline, and the end of either listener's
250 ms accept-error backoff. With none of these pending, the loop sleeps
until a descriptor is ready.

The timeout is the time to that moment in whole milliseconds, rounded
down. A moment less than a millisecond away gives a timeout of zero, so
the loop polls again without sleeping until the moment has passed.

## 2.5.3 Service order [*loop.service-order]

After `poll` returns, ready descriptors are serviced in this order:

1. waiting native and stub TCP streams with definite hangup/error,
   cancelling their questions and upstream transactions;
2. upstream sockets — replies that have arrived are answers already owed;
3. queued native replies, then queued stub TCP replies, whose sockets are writable;
4. the stub UDP socket, reading at most 64 datagrams;
5. the stub TCP listener, accepting at most 64 connections;
6. stub TCP connections still sending queries;
7. the native listener, accepting at most 64 connections;
8. native connections still sending requests;
9. the netd channel;
10. the registry watch.

The fixed datagram/accept batches yield to the other doors and timers even
while one listener remains continuously ready. [*loop.listener-batches]

## 2.5.4 Timers after descriptors [*loop.timers-run-after-descriptors]

Once the ready descriptors have been serviced, the timers run, in this
order: transactions at or past their deadline fail over to their next
attempt (§4.6), a due netd reconnection is attempted (§3.2), stub TCP
connections 10 seconds old or older are closed, expired reply writes are
closed, and native connections 5 seconds old or older are closed.

## 2.5.5 Answers within an iteration [*loop.local-answers-within-the-iteration]

A question that can be answered without the network — synthetic, from
the cache, or refused — has its answer prepared and its first write
attempted within the iteration that read it.

A question that needs the network sends its first transaction within
that iteration too, and has its answer prepared in the iteration that
sees its deciding reply or its last deadline. Delivery may continue over
later writable events for a slow native or TCP reader.

## 2.5.6 Nonblocking reply writes [*loop.replies-written-blocking]

Native and stub TCP replies get one nonblocking write attempt immediately.
If the socket cannot accept the whole frame, the unwritten suffix is
queued and resumed when that socket is writable. Each ready event makes
one write attempt; partial progress is retained without duplicating bytes.
The connection closes after the whole frame is written, on a write error,
or when its fixed one-second write deadline expires. Progress does not
extend that deadline. Other inputs and timers continue to be serviced.

At most 256 unfinished native replies and 256 unfinished stub TCP replies
are queued. Each frame is bounded by its protocol ceiling (§5.1, §6.2).
Even when a queue is full, a new reply gets its immediate write attempt;
if it too needs queue space, that connection closes. Ready readers can
therefore still receive replies while stalled readers occupy the queue.

Stub UDP replies and upstream queries are not written this way. The
netd subscription request is written with a two-second timeout when
the channel is opened (§3.1).

---

# 3.1 Subscription

_Peios / Peios technical reference / resolvd / The Netd Channel_

> How resolvd subscribes to netd's DNS snapshots, reads the stream, and what ends the subscription.

The channel is the `subscribe` request of PSPU §6.9, on netd's control
socket. This chapter describes resolvd's end of it; what netd puts in a
snapshot and when it sends one is described in the netd TRM.

## 3.1.1 Connecting

### 3.1.1.1 The subscribe request [*netd-subscribe.connect-and-send-subscribe]

When a connection attempt is due (§3.2), resolvd:

1. connects to `/run/netd/control.sock`;
2. sets a two-second write timeout and writes the `subscribe` request in
   netd's framing — a four-byte little-endian length and a MessagePack
   map;
3. switches the socket to nonblocking mode.

The request needs netd's `NETWORK_QUERY` right for resolvd's account;
whether that is granted is netd's decision.

### 3.1.1.2 A failed attempt [*netd-subscribe.any-step-failure-is-failed-attempt]

A failure at any of the three steps is a failed attempt, and the next
one is scheduled by the backoff of §3.2.

### 3.1.1.3 A successful attempt [*netd-subscribe.success-logged]

On success resolvd logs `subscribed to netd` at info level, discards
anything buffered from an earlier connection, and waits for the first
snapshot as an ordinary read on the channel. Only a successfully decoded
snapshot resets its backoff (§3.2); connection success alone does not.

### 3.1.1.4 Nothing else is written to netd [*netd-subscribe.writes-only-the-request]

resolvd writes nothing more on the channel after the request.

## 3.1.2 Reading the stream

When the channel is readable, resolvd reads everything available, in
8 192-byte chunks, and then takes every complete frame from what it
holds, in order:

| Frame | What resolvd does |
|---|---|
| A `snapshot` reply | Applies it (§3.3), once the whole read has been taken. Several in one read are applied one after another, so the last wins. [*netd-subscribe.snapshots-applied-in-order] |
| An error reply | Logs `netd refused the subscription: <message>`, and drops the channel once the whole read has been taken. [*netd-subscribe.error-reply-drops-channel] |
| Any other well-formed reply | Ignores it. [*netd-subscribe.other-replies-ignored] |
| A payload that does not decode | Logs `netd sent something unreadable: <error>`, and drops the channel once the whole read has been taken. [*netd-subscribe.undecodable-payload-drops-channel] |
| A length above 65 536 bytes | Stops taking frames: the rest of the buffer is discarded and the channel dropped. [*netd-subscribe.oversized-frame-drops-channel] |

### 3.1.2.1 Frames behind an error are still taken [*netd-subscribe.frames-after-error-still-taken]

An error reply or an unreadable payload does not stop the frames behind
it. Every complete frame already read is still taken, in order, and any
snapshot among them is applied. The same holds when the read ended at
end of file or a read error: the frames read before it are all taken.
Only a length above 65 536 stops the taking, and the frames before it
have been taken by then.

### 3.1.2.2 The channel is dropped before the read's snapshots are applied [*netd-subscribe.drop-logged-before-snapshots-applied]

When a read ends with the channel to be dropped, it is dropped — and
`lost the netd channel; reconnecting` logged — after every frame has
been taken and before any snapshot from that read is applied. In the
log, the `lost …` line therefore comes before the `netd: …` summary
lines (§3.3) of the snapshots read with it, and `status` then shows
`netd` as `false` together with the scopes of those snapshots, until
the channel is reconnected.

### 3.1.2.3 A partial frame [*netd-subscribe.partial-frame-buffered]

A partial frame stays buffered until the rest arrives.

### 3.1.2.4 End of file or a read error [*netd-subscribe.eof-or-read-error-drops-channel]

End of file, or any read error other than `EINTR` or `EAGAIN`, drops the
channel. `EINTR` is retried, and `EAGAIN` ends the read.

### 3.1.2.5 Dropping the channel [*netd-subscribe.drop-logged-and-reconnect-scheduled]

Dropping the channel closes the socket, discards the buffer, logs
`lost the netd channel; reconnecting` at warn level — once, however many
of the causes above occurred in the read — and schedules a reconnection
(§3.2).

---

# 3.2 Reconnection and Backoff

_Peios / Peios technical reference / resolvd / The Netd Channel_

> When resolvd tries to reach netd again, the exact backoff sequence, what it logs, and what it keeps answering with while netd is away.

Failure and reconnection are covered by PSPU §6.9. resolvd's backoff
starts at 0.5 seconds and doubles to a ceiling of 10 seconds.

## 3.2.1 The backoff sequence [*netd-reconnect.backoff-sequence]

The first attempt is made during startup, before readiness (§2.2). Each
failed attempt schedules the next one the current backoff later, then
doubles the backoff up to 10 seconds. Measured from a first attempt that
fails, attempts are made at:

| Attempt | Time after the first |
|---|---|
| 2 | 0.5 s |
| 3 | 1.5 s |
| 4 | 3.5 s |
| 5 | 7.5 s |
| 6 | 15.5 s |
| 7 and later | every 10 s |

## 3.2.2 The unreachable warning [*netd-reconnect.first-failure-logged-once]

A failed attempt made before any connection has succeeded is logged as
`netd not reachable (<error>); retrying` when it is the first failure,
and not logged otherwise. Together with the rule after a loss (below),
the warning is written at most once in the life of the process: for the
first failure, when that failure comes before the first successful
connection. A resolvd whose first attempt succeeds never writes it.

## 3.2.3 A good snapshot resets the backoff [*netd-reconnect.success-resets-backoff]

A successfully decoded snapshot resets the backoff to 0.5 seconds.
Connecting, writing the subscription, or receiving another reply kind
does not reset it. A partial snapshot does not reset it until complete.

## 3.2.4 After a loss

When an established channel is dropped (§3.1):

- the next attempt is due after the current backoff, then the delay
  doubles up to 10 seconds. After a channel that delivered a snapshot,
  successive gaps are 0.5, 1, 2, 4, 8, then 10 seconds. A connection
  lost before its first snapshot continues the existing sequence; [*netd-reconnect.sequence-after-loss]
- the `netd not reachable` warning is not logged for the failures that
  follow, however many there are; the `lost the netd channel;
  reconnecting` line that preceded them is the record. [*netd-reconnect.no-unreachable-warning-after-loss]

### 3.2.4.1 A refused subscription also backs off [*netd-reconnect.refusal-reconnects-every-half-second]

A netd that accepts the connection and then answers the subscription
with an error has not delivered a snapshot. Its retries follow the same
growing delay, up to ten seconds, as other failures. Each connection
still logs `subscribed to netd` at info level and two warnings (§9.4).
An accept-then-close or malformed reply likewise preserves the growing
delay until a good snapshot arrives.

## 3.2.5 Disconnected from netd [*netd-reconnect.state-kept-while-disconnected]

Losing netd changes nothing in the engine. The scopes from the last
snapshot, the hostname taken from it, and every cached answer stay in
use until the next snapshot replaces them; questions are answered
exactly as before, and the servers in those scopes are still asked.
`status` reports `netd` as `false` (§5.3).

## 3.2.6 Before the first snapshot [*netd-reconnect.no-snapshot-yet-behaviour]

When resolvd has never received a snapshot it has no scopes. It answers
synthetic and static names, uses the fallback servers if any are
configured, and answers everything else `unavailable` (§4.4).

---

# 3.3 Applying a Snapshot

_Peios / Peios technical reference / resolvd / The Netd Channel_

> How each snapshot becomes resolvd's hostname and scope list — the conversion of each field, what is dropped, and which cached answers are discarded.

Each snapshot replaces resolvd's scopes whole (PSPU §6.9). Applying one
has three parts: the hostname, the scope list, and the cache.

## 3.3.1 The hostname

- The snapshot's `hostname` is used when it is not empty. When it is
  empty, resolvd reads the kernel's hostname at that moment, into a
  256-byte buffer; a kernel hostname that is empty or `(none)` counts as
  none. [*netd-snapshot.empty-hostname-reads-kernel-hostname]
- The result is parsed as a domain name. A name that does not parse, or
  the root, leaves resolvd with no hostname: no synthetic answer is
  given for it, and `status` reports an empty `hostname`. [*netd-snapshot.unparseable-hostname-is-no-hostname]
- The kernel's hostname is otherwise read only at startup. A change to
  it is not seen until the next snapshot with an empty `hostname`. [*netd-snapshot.kernel-hostname-read-only-at-snapshot-or-startup]

## 3.3.2 The scope list

Each `DnsScope` in the snapshot becomes one scope, in snapshot order:

| Snapshot field | Becomes |
|---|---|
| `ifid` | The scope key [*netd-snapshot.ifid-is-scope-key] |
| `name` | The interface name reported in answers and in `status` [*netd-snapshot.name-is-reported-interface] |
| `servers` | Server addresses, in order. Malformed addresses are logged and dropped. Link-local IPv6 addresses use the containing interface; an optional `%name` or `%index` must match it (§4.7). There is no port; servers are asked on port 53. [*netd-snapshot.servers-parsed-malformed-dropped] |
| `domains` | Search domains, in order. A string that does not parse as a name, and the root, are dropped. [*netd-snapshot.domains-parsed-malformed-dropped] |
| `addresses` | `(address, prefix length)` pairs. A string that is not `address/prefix` is dropped. [*netd-snapshot.addresses-parsed-malformed-dropped] |
| `default_route`, `exclusive`, `metric`, `level` | Carried as they are |
| `ntp` | Ignored |

The `ifid` is used as it arrives. netd's interface ids are UUIDs, so
none is `fallback`, the key of the fallback scope (§4.4); a scope that
did arrive with that key would share the fallback scope's cache
entries, would be asked through the fallback servers, and would lend
its interface name to the fallback scope's answers.

### 3.3.2.1 Every scope is kept [*netd-snapshot.every-scope-kept]

Dropped entries are dropped silently. Every scope in the snapshot is
kept, whatever its level or server count; which of them take part in
routing is decided per question (§4.4).

### 3.3.2.2 The snapshot summary line [*netd-snapshot.summary-log-line]

resolvd then logs one line at info level summarising the snapshot:

```text
netd: eth0: 1 server(s), 1 domain(s), default; wg0: 1 server(s), 0 domain(s)
```

with `, default` on each scope that claims the default route, or
`netd: no scopes` for an empty snapshot. The counts are of the servers
and domains left after parsing.

## 3.3.3 The cache

Comparing the new scope list with the old one, by scope key:

- a scope whose key is new loses any cached answers under that key; [*netd-snapshot.new-scope-key-flushed]
- a scope whose server list differs from before — in content or in
  order — loses its cached answers; [*netd-snapshot.changed-server-list-flushes-scope]
- a scope missing from the new snapshot loses its cached answers; [*netd-snapshot.missing-scope-flushed]
- a change to a scope's domains, addresses, flags, metric or level
  leaves its cache alone. [*netd-snapshot.other-changes-keep-cache]

Demotion is kept per server address, not per scope, and survives every
snapshot (§4.6).

## 3.3.4 Questions already in flight

- A candidate is routed when it is first asked (§4.4), and its retries
  stay with the scope key it was routed to. When that scope's servers
  change, retries use the new list; when the scope has gone, the next
  retry finds no servers and the question ends `unavailable`. [*netd-snapshot.in-flight-retries-follow-scope-key]
- A transaction already sent is not cancelled by a snapshot, and its
  reply is cached under the scope key it was routed to. [*netd-snapshot.in-flight-reply-cached-after-flush]

---

# 4.1 How a Question Is Answered

_Peios / Peios technical reference / resolvd / The Engine_

> The path every question takes through the engine, in order — parsing, synthetic names, candidates, routing, the cache, the network — and what each way out reports as its source.

Every door ends here. The native socket's `resolve`, `lookup` and
`reverse`, and every stub query, become one or more tasks, and each task
takes the same path.

## 4.1.1 The path

1. **Parse.** The name is parsed from presentation form. A trailing dot
   makes no difference. Escapes are not interpreted; a label is the
   bytes between dots. A name with an empty label, a label over 63
   bytes, or more than 255 bytes on the wire does not parse, and the
   task is answered `notfound` at once. [*engine-flow.unparseable-name-is-notfound]
2. **Synthetic names.** The name as asked is checked against the
   synthetic names (§4.2). A match answers the task. [*engine-flow.synthetic-check-first]
3. **Candidates.** The name becomes its list of candidates (§4.3). An
   empty list — a single label with no applicable search domain —
   answers the task `notfound`. [*engine-flow.no-candidates-is-notfound]
4. **Routing.** Each candidate in turn is routed to one scope (§4.4).
   When no scope can take it, the task is answered `unavailable`. [*engine-flow.unroutable-candidate-is-unavailable]
5. **The cache.** Unless the request asked to bypass the cache, the
   cache is consulted under that scope (§4.5).
6. **The network.** Otherwise the candidate is sent upstream (§4.6 to
   §4.8).
7. **The next candidate.** A `notfound` for a candidate — from the cache
   or from a server — moves to the next candidate when there is one.
   Any other result answers the task. [*engine-flow.notfound-moves-to-next-candidate]
8. **The last candidate.** When the last candidate is `notfound`, the
   task is answered `notfound`. [*engine-flow.all-candidates-notfound-is-notfound]

## 4.1.2 The empty name is the root [*engine-flow.empty-name-is-root]

An empty name, or `.` alone, parses as the root. The root is not a
single label, so it is not expanded (§4.3): it is its own one
candidate, routed and asked like any other name.

## 4.1.3 Each candidate is routed separately [*engine-flow.each-candidate-routed-separately]

Each candidate is routed on its own, so the expansions of one single
label can go to different scopes. Each has its own attempt budget
(§4.6).

## 4.1.4 What each way out reports

| Ends at | Outcome | `source` | `server` | `interface` | `rcode` |
|---|---|---|---|---|---|
| Name does not parse | `notfound` | `local` | nil | nil | 0 [*engine-flow.report-unparseable] |
| Synthetic name | per §4.2 | `synthetic` or `hosts` | nil | nil | 0 [*engine-flow.report-synthetic] |
| No candidates | `notfound` | `local` | nil | nil | 0 [*engine-flow.report-no-candidates] |
| No scope can take the candidate | `unavailable` | `local` | nil | nil | 0 [*engine-flow.report-unroutable] |
| Cache hit | as cached | `cache` | the server whose reply was cached | the scope's interface | the cached reply's response code [*engine-flow.report-cache-hit] |
| A server's reply | `found` or `notfound` | `dns` | the server | the scope's interface | the server's response code [*engine-flow.report-server-reply] |
| Every candidate `notfound` | `notfound` | as the last candidate's cache hit or server's reply, in the two rows above | as that row | as that row | as that row [*engine-flow.report-all-candidates-notfound] |
| Attempts exhausted | `unavailable` | `local` | nil | the scope's interface | 0 [*engine-flow.report-attempts-exhausted] |
| No servers left in the scope | `unavailable` | `local` | nil | nil | 0 [*engine-flow.report-scope-without-servers] |
| In-flight ceiling reached | `unavailable` | `local` | nil | nil | 0 [*engine-flow.report-in-flight-ceiling] |

A single label whose every expansion came back `NXDOMAIN` from a server
therefore reports `dns`, the server that answered the last candidate,
that candidate's scope's interface, and response code 3; when the last
candidate's `NXDOMAIN` came from the cache, it reports `cache` with the
cached server and code.

### 4.1.4.1 Answers from the fallback scope [*engine-flow.fallback-scope-has-no-interface]

`interface` is nil for every answer from the fallback scope, which has
no interface: its cache hits, its servers' replies and its exhausted
attempts alike.

### 4.1.4.2 Validation [*engine-flow.validation-always-unvalidated]

`validation` is `unvalidated` in every case: resolvd performs no DNSSEC
validation (PSPU §6.6).

## 4.1.5 What an answer carries

An answer's records are the answer-section records of the reply that
decided it, after the filtering of §4.8, or the synthetic records of
§4.2. The engine also carries the candidate the answer was found at; the
stub door uses it to add a `CNAME` (§6.2) and `lookup` uses it as the
start of its CNAME chase (§4.9).

## 4.1.6 An asker that leaves

While a native or stub TCP question is waiting for the engine, its stream
is polled for hangup and errors. A definite hangup, error or invalid
socket abandons the question before ready upstream replies are processed:
all of its transactions are cancelled, including both families of an
`any` lookup. Other questions continue normally.
[*engine-flow.departed-asker-transactions-cancelled]

Replies to cancelled transactions are ignored; they do not populate the
cache. Answers already cached before cancellation remain cached.
[*engine-flow.cancelled-replies-not-cached]

An abandoned question is removed from the waiting clients and no answer
write is attempted for it. Cancellation does not count as an upstream
failure, refusal or answer. [*engine-flow.departed-asker-not-answered]

A write-half-close is supported: the client may stop sending while still
waiting for its answer. Readable end of file alone therefore does not
cancel a question. In particular, a TCP FIN cannot reliably distinguish a
complete departure from a write-half-close. Without a definite hangup or
error, the request retains its admission slot and runs through the usual
engine attempts and deadlines. UDP has no disconnect notification.

---

# 4.2 Synthetic Names

_Peios / Peios technical reference / resolvd / The Engine_

> The names resolvd answers before any network — localhost, the static names, the machine's own name, .local, and their reverses — in the order it checks them and for which record types.

PSPU §6.7 lists the names a resolver answers before any network.
resolvd checks them in a fixed order, on the name as asked and before
any search expansion, and the first match answers the task.

## 4.2.1 The order

1. **`localhost`, or any name under it.** `127.0.0.1` for `A`, `::1`
   for `AAAA`, both for `ANY`. Source `synthetic`. [*engine-synthetic.localhost]
2. **A static name.** An exact, case-insensitive match against the
   `Hosts\` values (§2.3). The name's addresses of the asked family —
   IPv4 for `A`, IPv6 for `AAAA`, both for `ANY`. Source `hosts`. [*engine-synthetic.static-name]
3. **The machine's hostname.** An exact, case-insensitive match against
   the hostname (§3.3), answered with the machine's own addresses
   (below). Source `synthetic`. [*engine-synthetic.hostname]
4. **`.local`.** The name `local` or any name under it is `notfound`,
   whatever the type asked. Source `synthetic`. No query is sent. [*engine-synthetic.local-is-notfound]
5. **A reverse-mapping name, for `PTR` or `ANY` only.** See below.

A name that matches none of these goes on to search expansion and the
network.

### 4.2.1.1 Static names beat the hostname and `.local` [*engine-synthetic.check-order]

Because the order is fixed, a static name beats the hostname and
`.local`: a `Hosts\` value named `printer.local` is answered with its
addresses, and one named after the machine overrides the machine's own
addresses.

### 4.2.1.2 `localhost` beats a static name [*engine-synthetic.static-name-under-localhost-unused]

`localhost` is checked first, so a `Hosts\` value named `localhost`, or
a name under it such as `printer.localhost`, never answers a forward
question: the synthetic loopback answer is given instead. Its addresses
still answer reverse questions through the static-name row below,
except loopback addresses, which the loopback row takes first.

### 4.2.1.3 Record types [*engine-synthetic.other-types-are-empty-found]

For the three address-bearing kinds, the answer is `found` whatever type
was asked; a type other than `A`, `AAAA` or `ANY`, or a family the name
has no address in, gives `found` with no records. `localhost` asked for
`MX` is an empty `found`, not a `notfound`, and is not forwarded.

### 4.2.1.4 The machine's own addresses

- They are every address of every scope at `addressed` or better,
  whether or not the scope has servers, in snapshot order, link-local
  addresses included and loopback addresses excluded. [*engine-synthetic.own-addresses-from-every-addressed-scope]
- Repeated addresses are removed only when they are adjacent in that
  list, so an address on two interfaces that are not consecutive in the
  snapshot appears twice. [*engine-synthetic.only-adjacent-duplicates-removed]
- When there are none, the hostname is answered with `127.0.0.1` and
  `::1`. [*engine-synthetic.hostname-without-addresses-is-loopback]

## 4.2.2 Reverse names

### 4.2.2.1 Reverse-mapping names [*engine-synthetic.reverse-name-forms]

A reverse-mapping name is one under `in-addr.arpa` with exactly four
labels before the suffix, or one under `ip6.arpa` with exactly 32. The
suffix is matched case-insensitively.

- Each `in-addr.arpa` label parses as a decimal number from 0 to 255.
  Leading zeros and a leading `+` are accepted, so `1.0.0.0127.in-addr.arpa`
  and `+1.0.0.127.in-addr.arpa` both name `127.0.0.1`.
- Each `ip6.arpa` label is a single hexadecimal digit, in either case.

A name of either shape whose labels do not parse is not a
reverse-mapping name.

### 4.2.2.2 Synthetic reverse answers

Asked for `PTR` or `ANY`, a reverse-mapping name is answered
synthetically when its address is:

| Address | Answer | Source |
|---|---|---|
| A loopback address — anything in `127.0.0.0/8`, or `::1` | `PTR localhost` | `synthetic` [*engine-synthetic.reverse-loopback] |
| Listed under a static name | `PTR` that name | `hosts` [*engine-synthetic.reverse-static-name] |
| One of the machine's own addresses, when there is a hostname | `PTR` the hostname | `synthetic` [*engine-synthetic.reverse-own-address] |

The rows are tried in that order.

### 4.2.2.3 An address shared by static names [*engine-synthetic.shared-static-address-reverse-per-configuration]

When several static names list the same address, resolvd returns one PTR:
the name whose ASCII-lowercased presentation form sorts first in byte order.
For example, `alpha.home` wins over `beta.home`. The answer is independent
of registry insertion order, hash-table order, reloads and restarts while
the set of matching names stays the same. It changes when that set changes
and another name sorts first. The selected name retains its configured
spelling in the reply. This rule applies to IPv4 and IPv6, for both `PTR`
and `ANY` questions.

### 4.2.2.4 Other reverse questions [*engine-synthetic.other-reverse-questions-go-to-network]

A reverse name asked for any other type, and a reverse name whose
address matches none of the rows, goes to the network.

## 4.2.3 Properties of every synthetic answer

- Every synthetic record has TTL 0. [*engine-synthetic.ttl-zero]
- Synthetic answers are never cached, and the `no_cache` flag has no
  effect on them. [*engine-synthetic.never-cached]
- Each one adds one to the `synthetic` counter (§4.10).

## 4.2.4 Expanded candidates are not checked [*engine-synthetic.expanded-candidates-not-checked]

Only the name as asked is checked. An expanded candidate is never
compared with the synthetic names, so with a static name `printer.lan`
and the search domain `lan`, a question for `printer` is expanded and
sent to the network as `printer.lan`.

---

# 4.3 Single-Label Expansion

_Peios / Peios technical reference / resolvd / The Engine_

> Which search domains resolvd applies to a single-label name, in what order, how the candidates are built and asked, and what never gets expanded.

PSPU §6.7 sets the search expansion rules. This article is how resolvd
builds and asks the list of candidates.

## 4.3.1 What counts as a single label

- A name is single-label when it parses to exactly one label. The
  trailing dot plays no part: `printer.` is single-label and is expanded
  exactly as `printer` is. [*engine-expansion.trailing-dot-does-not-suppress-expansion]
- A top-level domain such as `com` is a single label too, and cannot be
  asked for directly: it is asked only as its expansions. [*engine-expansion.top-level-domain-cannot-be-asked]
- Any other name — a multi-label name, or the root — has one candidate,
  itself. [*engine-expansion.multi-label-single-candidate]

## 4.3.2 The applicable domains

The table and serverless exclusion below describe
[source `b4f7085`](https://github.com/peios/resolvd/blob/b4f70857729069952762f8e2a2b56357b15d4760/resolvd/src/engine.rs).

| When | Domains, in order |
|---|---|
| Routing's step 1 applies: a routable exclusive scope is at `addressed` or better (§4.4) | That scope's search domains, and nothing else [*engine-expansion.exclusive-scope-domains-only] |
| Otherwise | The search domains of every routable scope (§1.3), scopes ordered by metric, then `ExtraSearchDomains` [*engine-expansion.routable-scope-domains-then-extra] |

The exclusive scope is the one routing would choose (§4.4).

- An up scope that has search domains and no servers is not routable,
  and contributes none of them. [*engine-expansion.scope-without-servers-contributes-no-domains]
- Scopes with equal metrics keep their snapshot order, and each scope's
  domains keep their own order.
- `ExtraSearchDomains` is not applied while routing's step 1 applies. [*engine-expansion.extra-domains-ignored-under-exclusive]

### 4.3.2.1 Proposed serverless-scope correction [*engine-expansion.up-scope-domains]

The [proposed source correction](https://github.com/peios/resolvd/blob/f5190f8bd47ae74da73e0c1ffa405f86eb782acf/resolvd/src/engine.rs) uses all
scopes at `link` or better, including scopes with no DNS servers. Without
a qualifying exclusive scope, their domains are ordered by metric and
then followed by `ExtraSearchDomains`; equal metrics retain snapshot
order and each scope keeps its domain order.

An exclusive scope at `addressed` or better supplies only its own domains,
even without servers: other scopes' domains and `ExtraSearchDomains` are
not used. For a name not already answered synthetically, if that scope
has no domains, a bare single-label name has no candidates and remains
`notfound` without a query. If expansion produces a candidate
that selects a serverless scope and needs upstream work, it is
`unavailable`; later candidates are not tried. Synthetic-answer and
cache handling remain unchanged.

This describes proposed source behavior, not a released package or an
installed resolver. The source-revision qualification in §4.4 applies.

## 4.3.3 Building the candidates

Each domain in turn is appended to the label.

- A candidate already in the list, compared case-insensitively, is not
  added again. [*engine-expansion.duplicate-candidates-removed]
- A candidate longer than 255 bytes on the wire is left out. [*engine-expansion.overlong-candidates-left-out]

With no candidates left the name is `notfound` without a query (§4.1).

## 4.3.4 The case of a candidate [*engine-expansion.candidate-case-preserved]

A candidate keeps the case of the label as asked and of the domain as
configured; that is the case its records are reported at (§4.8).

## 4.3.5 Asking the candidates

- Candidates are asked one at a time, in order. Each is routed on its
  own (§4.4) and has its own attempt budget (§4.6). [*engine-expansion.candidates-asked-one-at-a-time]
- A `notfound` — from a server or from the cache — moves to the next
  candidate (§4.1).
- `found`, with records or without, is the answer, and later candidates
  are not asked. [*engine-expansion.found-or-nodata-ends-expansion]
- `unavailable` is the answer, and later candidates are not asked. [*engine-expansion.unavailable-ends-expansion]
- When the last candidate is `notfound`, the question is `notfound`
  (§4.1).

---

# 4.4 Scope Routing

_Peios / Peios technical reference / resolvd / The Engine_

> How resolvd picks the one scope a candidate goes to — which scopes take part at all, each step of the order as implemented, and how ties are broken.

PSPU §6.7 gives the routing order. resolvd applies it to each candidate
separately, when the candidate is first asked, and the candidate's
retries stay with the scope it chose.

The server-availability exclusions below describe
[source `b4f7085`](https://github.com/peios/resolvd/blob/b4f70857729069952762f8e2a2b56357b15d4760/resolvd/src/engine.rs).
They differ from PSPU's definition of up. The proposed correction is
summarised after the historical exclusive-scope case.

## 4.4.1 Which scopes take part [*engine-routing.only-routable-scopes-take-part]

Only **routable scopes** (§1.3) take part: those that are up — at level
`link` or better (PSPU §6.2) — and have at least one server. An up scope
with no servers is skipped at every step: it is never chosen, it never
wins a tie, and its flags have no effect on routing. The same set
decides which search domains apply (§4.3).

## 4.4.2 The steps

1. **Exclusive.** The routable scopes that are exclusive and at level
   `addressed` or better. The one with the lowest metric takes the
   candidate; among equal metrics, the first in snapshot order. [*engine-routing.step-exclusive]
2. **Search domain.** Among the routable scopes, every search domain the
   candidate equals or ends with, compared label by label and
   case-insensitively. The scope holding the matching domain with the
   most labels takes the candidate. [*engine-routing.step-search-domain]
3. **Reverse subnet.** When the candidate is a reverse-mapping name
   (§4.2), the routable scopes that hold an address of the same family
   whose prefix contains the named address; a prefix length beyond the
   family's width counts as the full width. The lowest metric wins, then
   the earliest. [*engine-routing.step-reverse-subnet]
4. **Default route.** The routable scopes with `default_route` set. The
   lowest metric wins, then the earliest. [*engine-routing.step-default-route]
5. **Lowest metric.** Otherwise, the routable scope with the lowest
   metric, then the earliest. This step is reached whenever no routable
   scope claims the default route — including when the only claimant
   has no servers. [*engine-routing.step-lowest-metric]
6. **Fallback.** When there are no routable scopes at all, the fallback
   scope, provided `FallbackServers` names at least one server. Its
   scope key is `fallback`. [*engine-routing.step-fallback]

When none of the steps yields a scope, the candidate is `unavailable`
without a query (§4.1).

### 4.4.2.1 An exclusive scope that does not qualify [*engine-routing.exclusive-without-servers-or-address-not-exclusive]

An exclusive scope at level `link`, or one with no servers, is not
exclusive for routing, and routing continues with step 2. The first
takes part in the later steps as an ordinary scope; the second, not
being routable, in none of them.

### 4.4.2.2 Proposed serverless-scope correction [*engine-routing.serverless-scope-keeps-selection]

The [proposed source correction](https://github.com/peios/resolvd/blob/f5190f8bd47ae74da73e0c1ffa405f86eb782acf/resolvd/src/engine.rs) treats
level `link` or better as up, independently of DNS servers:

- Steps 1–4 consider up scopes even when their server lists are empty.
  Exclusive selection still requires `addressed` or better. Domain,
  subnet, metric and snapshot-order rules are unchanged.
- When one of those steps selects a scope without servers and upstream
  work is needed, the candidate is `unavailable` without a query. It
  does not fall through to another interface or the fallback scope.
- Step 5 is reached only when earlier steps select no scope. This
  ordinary lowest-metric choice still requires an up scope with servers.
  A serverless default-route claimant therefore blocks step 5, while a
  serverless scope that matches no earlier rule does not block it.
- Fallback is considered only if no earlier step selects a scope and
  no up scope has servers. A serverless winner at an earlier step blocks
  fallback too.

An exclusive scope at `link` alone still does not qualify for step 1;
it can participate in later steps as an ordinary up scope. Synthetic
answers and the selected scope's cache retain their existing precedence;
this correction does not change cache invalidation or retry policy.
These are proposed source semantics, not a released-package or
installed-resolver guarantee. Check the installed source revision.

### 4.4.2.3 Ties between search domains [*engine-routing.search-domain-tie-break]

Between matching domains with the same number of labels, the one in the
scope with the lower metric wins. With equal metrics too, the first
match found wins: the earlier scope in snapshot order, and within one
scope the earlier domain.

## 4.4.3 Consequences

### 4.4.3.1 Step 1 takes every candidate [*engine-routing.exclusive-takes-every-candidate]

Step 1 is absolute while it applies: with an exclusive scope chosen,
every candidate goes to it, whatever its name, and step 2 never runs.

### 4.4.3.2 Search domains beat the subnet rule [*engine-routing.search-domain-beats-subnet]

Step 2 runs before steps 3 and 4, so a scope whose search domain is
`in-addr.arpa` or `10.in-addr.arpa` takes reverse names ahead of the
subnet rule.

### 4.4.3.3 Fallback servers are a last resort [*engine-routing.fallback-unused-while-any-interface-has-a-server]

Fallback servers are used only while no scope is routable. As soon as
one scope at `link` or better has a server, the fallback scope takes
nothing. Answers cached through it are then not consulted, and stay in
the cache until they are flushed, overwritten or evicted (§4.5).

### 4.4.3.4 A candidate asks only its own scope's servers [*engine-routing.candidate-asks-only-its-scope]

Every attempt for a candidate, and the TCP retry after a truncated
reply, goes to a server of the one scope the candidate was routed to.
When those attempts fail, the candidate is `unavailable` (§4.6); the
servers of other scopes, and the fallback servers, are not asked for
it.

### 4.4.3.5 Demotion does not affect routing [*engine-routing.demotion-does-not-affect-routing]

Routing reads only the current scope list. Which servers are demoted
has no effect on which scope a candidate goes to: a scope whose every
server is demoted still takes the candidates routing gives it.

---

# 4.5 The Cache

_Peios / Peios technical reference / resolvd / The Engine_

> What resolvd caches and for how long, the key, the capacity and how entries are evicted, what a hit reports, and every event that flushes it.

The cache's key and lifetimes are set by PSPU §6.7. This article is how
resolvd computes them and what it does at the edges.

## 4.5.1 The cache key

- An entry is keyed by the candidate in lower case, the record type, and
  the scope key (§1.3). The class is not part of the key; every question
  resolvd asks is class `IN`. The stub rejects other classes before
  consulting the cache (§6.1), even when a matching `IN` answer is held. [*engine-cache.key-is-lowercase-candidate-type-scope]
- The same name asked through two scopes is two entries, and a single
  label's expansions are each cached under the expanded name. [*engine-cache.expansions-cached-under-expanded-name]

## 4.5.2 What is stored

Only replies a server sent with response code `NOERROR` or `NXDOMAIN`
are stored, under the candidate and the scope the candidate was routed
to:

| Reply | Stored as | Lifetime |
|---|---|---|
| `NOERROR` with relevant answer records of class `IN` (§4.8) | `found` with those records | The least TTL among those records, capped at 86 400 s [*engine-cache.positive-lifetime] |
| `NOERROR` with no relevant answer records of class `IN` | `found` with none | Negative lifetime [*engine-cache.nodata-uses-negative-lifetime] |
| `NXDOMAIN` | `notfound` | Negative lifetime [*engine-cache.nxdomain-uses-negative-lifetime] |

### 4.5.2.1 The negative lifetime

- The negative lifetime comes from the first `IN` `SOA` record in the
  reply's authority section whose owner is the effective name or an
  enclosing domain. The effective name is the candidate after following
  its relevant CNAME path (§4.8); suffix matching respects label boundaries.
  Use the lesser of the SOA's `MINIMUM` field and its own TTL, capped at
  300 s. When an alias leads to NXDOMAIN, also cap the lifetime at the
  least TTL of that alias path. [*engine-cache.negative-lifetime-from-soa]
- A reply with no qualifying `SOA` in its authority section has a negative
  lifetime of zero. [*engine-cache.no-soa-negative-lifetime-zero]

### 4.5.2.2 A zero lifetime is not stored [*engine-cache.zero-lifetime-not-stored]

An entry whose lifetime is zero is not stored. A positive answer with a
zero TTL, and a negative answer without an `SOA`, are therefore never
cached.

### 4.5.2.3 What is never stored [*engine-cache.never-stored]

Never stored: synthetic answers, `unavailable`, transport failures, a
UDP reply with `TC` set (the reply to its TCP retry is the one
considered), and
replies with any other response code.

### 4.5.2.4 The TTLs inside an entry [*engine-cache.record-ttls-kept-entry-lifetime-capped]

The records in an entry are kept with the TTLs the server sent. Only the
entry's lifetime is capped.

## 4.5.3 Hits

- An entry is live while its expiry is in the future. A live entry is
  returned with each record's TTL lowered to the whole number of seconds
  the entry has left, rounded down, when that is less than the record's
  own. [*engine-cache.hit-ttl-lowered-to-remaining-seconds]
- A `notfound` hit with further candidates waiting moves to the next
  candidate, as a `notfound` from a server would (§4.1).

## 4.5.4 Bypassing the cache

- A `resolve` request with `no_cache` set skips the lookup for each of
  its candidates. The reply it gets is still stored. [*engine-cache.no-cache-skips-lookup-still-stores]
- Stub queries, `lookup` and `reverse` always consult the cache. [*engine-cache.other-doors-always-consult-cache]

## 4.5.5 Capacity and eviction

- The cache holds at most 8 192 entries. [*engine-cache.capacity]
- Expiry alone does not remove an entry. Looking up an expired key
  removes it immediately, before any replacement answer arrives. Otherwise
  it stays until overwritten, flushed, or removed during admission at
  capacity. The `cache_entries` count in `status` includes expired entries
  that have not yet been removed. [*engine-cache.expired-entries-linger-and-are-counted]

### 4.5.5.1 Eviction

Storing an entry under a key the cache does not already hold, when it
already holds 8 192 entries, first evicts:

1. every entry whose expiry is at or before the current time; [*engine-cache.eviction-sweeps-expired-entries]
2. then, if the cache still holds 8 192 entries, the one entry with the
   earliest expiry — any one of them, when several share it. [*engine-cache.eviction-then-earliest-expiry]

### 4.5.5.2 A long-lived answer preserves other live entries [*engine-cache.long-lived-entry-preserves-live-entries]

The sweep compares against the current time, independently of the new
answer's lifetime. A day-long answer arriving at a full cache of live,
shorter-lived answers replaces only the entry with the earliest expiry;
the cache still holds 8 192 entries. If removing expired entries makes
room, no live entry is evicted.

Older builds compared against the incoming answer's expiry and could
empty a cache of live answers.

### 4.5.5.3 Overwriting a held key [*engine-cache.overwrite-evicts-nothing]

Storing under a key the cache already holds replaces that entry, live
or expired, and evicts nothing else.

## 4.5.6 Flushes

| Event | Discards |
|---|---|
| A snapshot in which a scope is new, has a different server list, or is missing (§3.3) | That scope's entries |
| A registry change to `FallbackServers` in content or order (§2.3) | The fallback scope's entries |
| A `flush` request (§5.3) | Everything |
| A restart | Everything |

Nothing else discards entries. Changes to static names, search domains,
the hostname, `ControlSecurity`, scope flags and metrics leave the cache
as it is.

### 4.5.6.1 A reply after its scope was flushed [*engine-cache.late-reply-stored-after-flush]

In [source `b4f7085`](https://github.com/peios/resolvd/blob/b4f70857729069952762f8e2a2b56357b15d4760/resolvd/src/engine.rs),
a reply that arrives after its scope was flushed is still stored, under
the same scope key.

### 4.5.6.2 Proposed admission rule for invalidated tasks [*engine-cache.invalidated-task-does-not-store]

The [proposed source correction](https://github.com/peios/resolvd/blob/bc4efa36dd72a942169e4d5ad849f5806a4fd7f1/resolvd/src/engine.rs)
also invalidates pending tasks' permission to store answers. A global
`flush` affects every pending task. Removing an interface scope or
changing its server list affects tasks currently associated with that
scope; changing the fallback-server list affects tasks associated with
the fallback scope.

An invalidated task still completes its original requester normally,
but cannot store any further answers. This restriction persists through
UDP-to-TCP fallback, timeout retries and later search candidates, even
if a later attempt uses a new server list. Removing and re-adding a
scope does not restore the task's permission. Fresh requests can cache
normally.

Changes only to metrics, search domains or other scope properties with
unchanged server lists do not invalidate admission; unrelated scopes'
pending tasks are unaffected. This changes cache admission, not request
cancellation or routing. Cache keys, TTLs and eviction policy are
unchanged. It describes proposed source behavior, not a released package
or an installed resolver; check the installed source revision before
relying on it.

---

# 4.6 Servers, Attempts and Timeouts

_Peios / Peios technical reference / resolvd / The Engine_

> Which server each attempt goes to, what counts as a failure, how demotion works, how many attempts a question gets and how long it can take, and the in-flight ceiling.

Server order, demotion and the attempt limit are set by PSPU §6.7, and
the contract's mainline values are in PSPU §6.B. resolvd's values are:

| Bound | Value |
|---|---|
| Transaction timeout | 2 s [*engine-servers.transaction-timeout] |
| Attempts per question (all candidates and transports) | 3 [*engine-servers.attempts-per-candidate] |
| Demotion period | 30 s [*engine-servers.demotion-period] |
| Upstream transactions in flight | 4 096 [*engine-servers.in-flight-ceiling-value] |

## 4.6.1 Choosing the server

- For each attempt, the scope's servers — or the fallback servers — are
  put in order: the servers not currently demoted, in their configured
  order, followed by the demoted ones, in their configured order. [*engine-servers.healthy-first-then-demoted]
- The attempt goes to the first server in that order not yet asked for
  this candidate. When every server has been asked, it goes to the
  server at position *n* mod *count* in that order, where *n* is the
  number of attempts already made for the candidate. [*engine-servers.server-choice-rule]
- With one server, all three attempts go to it. [*engine-servers.single-server-gets-every-attempt]
- The order is computed afresh for each attempt, so a server demoted by
  the previous attempt is already behind its peers for the next. [*engine-servers.order-recomputed-each-attempt]
- Attempts for one candidate are made one at a time; a transaction is
  sent only after the previous one for the same candidate has ended. [*engine-servers.attempts-sequential]

With two healthy servers `A` and `B`, the attempts go to `A`, `B`, then
whichever of them is first in the order at that moment — `A` if both
have since been demoted, since demotion keeps configured order within
the demoted group.

## 4.6.2 What fails an attempt

Each of the following ends the transaction, adds one to
`upstream_failed`, demotes the server, and moves to the next attempt:

- no matching reply within 2 seconds of the transaction being sent; [*engine-servers.timeout-fails-attempt]
- an error opening, binding, connecting or sending on the transaction's
  socket, except the local resource and permission errors below — an
  unreachable network is known at once, not after the timeout; [*engine-servers.send-error-fails-attempt-at-once]
- an error receiving on a UDP socket, such as the refusal that follows
  an ICMP port-unreachable; [*engine-servers.udp-receive-error-fails-attempt]
- for TCP, the connection failing to complete: an error or hang-up
  while it is being established; [*engine-servers.tcp-connect-failure-fails-attempt]
- for TCP, an error, a hang-up or a failed write before the whole query
  has been sent; [*engine-servers.tcp-error-before-query-sent-fails-attempt]
- for TCP, the connection closing, or a read error, before a whole matching reply
  has arrived; [*engine-servers.tcp-incomplete-reply-fails-attempt]
- a matching reply whose response code is neither `NOERROR` nor
  `NXDOMAIN` — `SERVFAIL`, `REFUSED`, `FORMERR`, `NOTIMP`, and every
  other code, including EDNS extended codes. [*engine-servers.failure-rcodes-fail-attempt]

A synchronous failure to open or send a transaction caused by exhausted
local descriptors (`EMFILE`, `ENFILE`), memory or buffers (`ENOMEM`,
`ENOBUFS`), local address allocation (`EADDRNOTAVAIL`, `EADDRINUSE`), or
permission (`EACCES`, `EPERM`) still adds one `upstream_failed` and advances
the bounded retry sequence, but does not demote the server. A different
configured address or address family may still be usable. If all attempts
fail, the result is `unavailable` and is not cached.

A reply that does not match the transaction is ignored while the socket
remains open for a matching reply until the original deadline (§4.8).

## 4.6.3 Demotion

- Demoting a server marks its address until 30 seconds from now; a
  further failure moves that mark forward. [*engine-servers.demotion-marks-address-for-30-seconds]
- A matching reply with response code `NOERROR` or `NXDOMAIN` that
  resolvd uses — a UDP reply without `TC`, or a TCP reply — removes the
  server's mark at once. A UDP reply with `TC` set does not remove it,
  whatever its response code; the reply to the TCP retry decides. [*engine-servers.good-reply-clears-demotion]
- The mark belongs to the address, so a server listed by two scopes is
  demoted in both. [*engine-servers.demotion-is-per-address]
- A demoted server is still asked, after the healthy ones. [*engine-servers.demoted-servers-still-asked]
- `status` lists, for each scope, which of its servers are demoted at
  that moment (§5.3); demotion of the fallback servers is not reported. [*engine-servers.fallback-demotion-not-reported]

## 4.6.4 The attempt limit

- A question gets three upstream attempts across all search candidates and
  transports. Failures spend them: a timeout, a failure response code, or
  a transport failure. When its last attempt fails, the question is
  `unavailable` (§4.1). [*engine-servers.third-failure-is-unavailable]
- The budget is not reset when the question moves to another candidate,
  but a candidate's `NXDOMAIN` that moves it on is an answer, not a
  failure, and does not spend an attempt. Every search candidate is
  therefore asked, however many there are. Cached answers spend no
  attempt. [*engine-servers.limit-is-per-question]
- The TCP retry following a truncated UDP reply (§4.7) spends one attempt
  and gets its own two-second deadline. A truncated reply on the final
  attempt cannot start TCP: the question is `unavailable`.
  [*engine-servers.tcp-retry-spends-an-attempt]

The three attempts allow at most six seconds of upstream timeout waiting
in total, including TCP and search-domain expansion. Each `NXDOMAIN` that
moves to another candidate adds one round trip. Loop scheduling and local
I/O can add elapsed time; the attempt limit does not give those
operations a separate deadline.

## 4.6.5 The in-flight ceiling

- When 4 096 or more transactions are outstanding, a question about to
  allocate an upstream transaction is answered `unavailable` at once, and the
  `refused` counter goes up by one. A question whose scope has no
  servers left is answered `unavailable` before the ceiling is looked
  at, and is not counted as refused. [*engine-servers.in-flight-ceiling-refuses-new-transactions]
- Every send checks this ceiling, including later attempts and TCP
  retries. A retry is normally sent after its previous transaction has
  ended and been removed, so it can reuse that slot even at capacity.
  Exhausting the three-attempt budget is checked before allocating a
  transaction, and is not counted as a capacity refusal. [*engine-servers.retries-checked-against-ceiling]
- Synthetic names and cache hits are answered as usual whatever the
  count. [*engine-servers.local-answers-unaffected-by-ceiling]

---

# 4.7 The Upstream Query

_Peios / Peios technical reference / resolvd / The Engine_

> What resolvd puts on the wire for each transaction — sockets and source ports, the message identifier and 0x20 case pattern, EDNS0, link-local servers, and the TCP retry.

PSPU §6.7 sets the upstream behaviour. This article is what resolvd puts
on the wire to meet it.

## 4.7.1 Sockets

### 4.7.1.1 One socket per transaction [*engine-query.socket-per-transaction]

Every transaction has its own socket, opened when the transaction is
sent and closed when it ends.

### 4.7.1.2 The UDP socket

- A new socket is bound to the unspecified address of the server's
  family, port 0, so the kernel picks the source address and a fresh
  ephemeral source port. [*engine-query.udp-fresh-ephemeral-port]
- The socket is connected to the server's port 53, so the kernel
  delivers only datagrams from that address and port. [*engine-query.udp-socket-connected-to-server]
- Each datagram is read into a 4 096-byte buffer; a longer datagram is
  truncated by the read. [*engine-query.udp-read-buffer-4096]

The query is sent with one `send`.

### 4.7.1.3 The TCP connection [*engine-query.tcp-one-query-per-connection]

A new nonblocking socket is connected to the server's port 53. When the
connection completes, the query is written with its two-byte length
prefix; once it is all written, the socket is read until the two-byte
length and that many bytes of reply have arrived. Bytes after the first
message are ignored. One query is sent per connection, and the
connection is closed when the transaction ends.

### 4.7.1.4 A transaction that cannot be sent [*engine-query.send-failure-logged]

A failure found while a transaction is being sent — opening or binding
its socket, connecting it, or, for UDP, sending the query — fails the
attempt at once (§4.6) and is logged at warn level as
`upstream <server>: <error>`. A TCP failure found later, once the
connection is under way, is not logged, and neither is a timeout or a
UDP receive error.

## 4.7.2 Link-local servers [*engine-query.link-local-server-uses-scope]

A link-local IPv6 server from netd is reached through the interface named
by its DNS scope. Both UDP and TCP upstream sockets carry that interface’s
index in `sin6_scope_id`; a truncated UDP answer keeps it on the TCP retry.
An address such as `fe80::1` can therefore be advertised in RDNSS without a
zone suffix. Failures of that address on one scope do not demote the same
address on another scope.

A snapshot may also spell it `fe80::1%eth0` or use a numeric interface
index. An explicit zone must match the containing scope’s interface;
invalid or mismatched zones are logged and ignored. Global addresses
continue to use ordinary unscoped sockets.
[*engine-query.snapshot-zone-matches-interface]

`FallbackServers` has no containing interface scope and continues to accept
plain IP addresses only: a `%zone` value is logged and skipped. Configure
link-local DNS on the interface profile instead. A bare link-local
fallback cannot be sent; it fails as a local address error, without
marking a remote server unhealthy.
[*engine-query.fallback-zone-unsupported]

## 4.7.3 The message

| Field | Value |
|---|---|
| ID | 16 bits from the kernel CSPRNG, fresh for every transaction [*engine-query.id-fresh-per-transaction] |
| Flags | `RD` set; `CD` clear; everything else clear [*engine-query.rd-set-other-flags-clear] |
| Question | The candidate, case-randomised; the record type asked; class `IN` [*engine-query.question-is-randomised-candidate-in-class-in] |
| Additional | One `OPT` record: payload size 1 232, `DO` clear, extended response code 0, version 0, no options [*engine-query.opt-record-advertises-1232] |

Authority and answer sections are empty.

### 4.7.3.1 Query entropy [*engine-query.cryptographic-entropy-per-transaction]

Every transaction obtains 34 fresh bytes through `getrandom` with
`GRND_NONBLOCK`: two bytes for the message ID, interpreted little-endian,
and 32 bytes of case bits, enough for every letter in a DNS name. This
uses the kernel CSPRNG without a predictable userspace generator or a
clock fallback.

If entropy is unavailable, including before the kernel CSPRNG is ready,
resolvd logs the error, increments `refused`, and settles the affected
question as `unavailable` without sending it. The event loop does not wait
for entropy readiness; later requests can try again. Synthetic and cached
answers do not need upstream entropy. [*engine-query.entropy-failure-is-unavailable]

### 4.7.3.2 Case randomisation

- Every ASCII letter in the candidate is flipped between upper and lower
  case when its random bit is 1. Digits, hyphens and
  other bytes are unchanged. [*engine-query.letters-flipped-on-generator-bits]
- Case bytes follow the ID bytes. Bits are taken lowest first from each
  byte in order. A bit is used only for a letter. [*engine-query.case-bits-consumed-per-letter]
- Each transaction — a retry to the same server, and the TCP retry after
  truncation, included — gets a new pattern and a new ID. [*engine-query.new-pattern-every-transaction]

## 4.7.4 No fallback without EDNS0 [*engine-query.no-fallback-without-edns]

Every query carries the `OPT` record. resolvd does not fall back to a
query without EDNS0: a server that answers an EDNS0 query with
`FORMERR` has failed the attempt (§4.6).

## 4.7.5 The TCP retry

- A matching UDP reply with `TC` set is not used. The same candidate is
  sent to the same server over TCP as a new transaction, with a new ID,
  a new case pattern and its own two-second deadline, if an attempt
  remains in the question budget. The TCP transaction spends one attempt
  (§4.6); without budget the question is `unavailable`. [*engine-query.truncated-udp-retried-over-tcp-same-server]
- A TCP reply with `TC` set is used as it is. [*engine-query.truncated-tcp-reply-used-as-is]

---

# 4.8 Reply Validation

_Peios / Peios technical reference / resolvd / The Engine_

> How resolvd decides a reply belongs to its transaction, what happens to one that does not, and what it takes from one that does — outcome, records, case and lifetime.

## 4.8.1 Matching

A reply is matched against the transaction it arrived for. It matches
when all of these hold:

1. it decodes as a DNS message — bytes after its last section are
   tolerated; [*engine-reply.match-decodes]
2. its ID equals the transaction's; [*engine-reply.match-id]
3. its `QR` bit is set; [*engine-reply.match-qr-set]
4. it has exactly one question; [*engine-reply.match-one-question]
5. that question's type and class equal those sent; [*engine-reply.match-type-and-class]
6. that question's name is byte-for-byte identical to the name sent,
   case pattern included. [*engine-reply.match-name-case-exact]

### 4.8.1.1 A non-matching reply is ignored [*engine-reply.non-matching-reply-ignored]

A reply that fails any of these is ignored: the transaction is not ended
by it, nothing is counted, and the server is not demoted for it.

### 4.8.1.2 A non-matching datagram on UDP [*engine-reply.non-matching-udp-datagram-becomes-timeout]

A datagram that does not match is discarded while the transaction's
socket stays open. A later matching datagram can answer the same
transaction. Ignoring a datagram does not extend the original two-second
deadline: if no matching reply arrives, the attempt times out and the
server is demoted (§4.6).

### 4.8.1.3 An undecodable or cut-short datagram [*engine-reply.undecodable-or-cut-short-datagram-becomes-timeout]

The same holds for a datagram that does not decode. A datagram longer
than the 4 096-byte read buffer arrives cut short. If the cut falls
inside the DNS message, it cannot decode and is discarded; the socket
remains available for another reply until the original deadline.

### 4.8.1.4 A non-matching TCP reply [*engine-reply.non-matching-tcp-reply-becomes-timeout]

An undecodable or non-matching TCP message is discarded while the
connection remains open. Later complete frames on that connection are
checked, including frames already buffered and a matching frame followed
by EOF. Partial frames remain buffered for the next read. Ignoring a
frame does not extend the original deadline. If the peer closes before a
matching frame completes, the attempt fails; if it stays open without a
matching reply, the attempt times out (§4.6).

## 4.8.2 A matching reply

A matching reply adds one to `upstream_answered` and ends the
transaction. Then:

| Reply | Result |
|---|---|
| UDP, `TC` set | TCP retry to the same server (§4.7) |
| Response code `NOERROR` | `found` [*engine-reply.noerror-is-found] |
| Response code `NXDOMAIN` | `notfound` [*engine-reply.nxdomain-is-notfound] |
| Any other response code | The attempt fails (§4.6) |

A `NOERROR` or `NXDOMAIN` reply that is used removes the server's
demotion; a UDP reply with `TC` set does not (§4.6).

### 4.8.2.1 Extended response codes [*engine-reply.extended-rcode-combined]

The response code is the header's four bits combined with the extended
bits of an `OPT` record in the reply, so an extended code is never
mistaken for `NOERROR` or `NXDOMAIN`.

## 4.8.3 What is taken from it

### 4.8.3.1 Records

- Relevant answer records of class `IN`, in the order the server
  sent them. [*engine-reply.answer-records-of-class-in]
- For `NXDOMAIN` none are taken, even if the server sent some. [*engine-reply.nxdomain-records-discarded]
- Start at the candidate. Keep records of the requested type at that
  owner; otherwise follow the first `IN` CNAME there, retaining it, and
  repeat at its target. Stop after sixteen aliases, on a repeated name,
  or when no matching record remains. Records unrelated to that path or
  of an unrequested type are discarded before rendering or caching.
  Their TTLs cannot shorten the cache lifetime. [*engine-reply.records-match-question]
- An explicit CNAME question returns CNAME records at the queried owner.
  ANY accepts all `IN` types at that owner and does not follow aliases.
  Owner comparisons are case-insensitive and use DNS label boundaries.
- The authority and additional sections are not returned. [*engine-reply.authority-and-additional-not-returned]

### 4.8.3.2 The case of returned records [*engine-reply.candidate-case-restored]

A record whose owner name equals the candidate, case-insensitively, is
given the candidate's case — the case of the question as asked, not the
randomised case sent. Other records keep the case the server used.

### 4.8.3.3 Lifetime

The cache lifetime of §4.5, computed from the answer records or from the
authority section's `SOA`.

### 4.8.3.4 Upstream flags [*engine-reply.upstream-flags-not-carried]

The server's `AA`, `AD`, `RA` and `CD` bits are not used and are not
carried into any answer.

### 4.8.3.5 An empty `NOERROR` [*engine-reply.empty-noerror-is-nodata]

A `NOERROR` reply with no relevant answer records of class `IN` — including a
referral from a server that does not recurse — is `found` with no
records.

A `notfound` with further candidates moves to the next one (§4.3);
everything else answers the question.

---

# 4.9 Lookup and Reverse

_Peios / Peios technical reference / resolvd / The Engine_

> How a lookup becomes one task per family and how their results are combined — outcome, CNAME chasing, the canonical name, address order and source — and how a reverse becomes a PTR question.

## 4.9.1 `lookup`

A `lookup` (PSPU §6.5) asks for `A`, `AAAA`, or both:

| `family` | Tasks |
|---|---|
| `any` | `A` and `AAAA`, started together [*engine-lookup.any-starts-both-families] |
| `inet` | `A` [*engine-lookup.inet-asks-a] |
| `inet6` | `AAAA` [*engine-lookup.inet6-asks-aaaa] |

### 4.9.1.1 Each task is an ordinary question [*engine-lookup.tasks-independent-reply-after-last]

Each task is an ordinary question (§4.1): it has its own synthetic
check, its own expansion, its own routing per candidate, its own cache
lookups and its own attempts. The two tasks of an `any` lookup run at
the same time and can be answered from different sources. The reply is
built when the last of them is done.

### 4.9.1.2 The outcome

| Tasks' outcomes | `outcome` |
|---|---|
| At least one `found`, with or without records | `found` [*engine-lookup.any-found-is-found] |
| None `found`, at least one `unavailable` | `unavailable` [*engine-lookup.unavailable-without-found] |
| Every one `notfound` | `notfound` [*engine-lookup.all-notfound-is-notfound] |

An `A` answer and an `AAAA` that is `unavailable` is `found`, with IPv4
addresses only.

### 4.9.1.3 Chasing CNAMEs

For each `found` task, resolvd looks for addresses in that task's
answer records, starting at the candidate the answer was found at.
Names are compared case-insensitively.

1. If the first record at the current name is a `CNAME`, and no record
   of the asked type is at the current name, the chase moves to the
   `CNAME`'s target. [*engine-lookup.cname-chase-follows-first-cname]
2. This repeats at most 16 times; a loop stops there. [*engine-lookup.cname-chase-depth-16]
3. The addresses are the `A` (for the `A` task) or `AAAA` (for the
   `AAAA` task) records at the name the chase ended at, in answer order,
   each with its record's TTL. [*engine-lookup.cname-chase-addresses-at-final-name]

### 4.9.1.4 The chase asks nothing further [*engine-lookup.chase-asks-no-further-question]

Only the records in the task's own answer are used. A chain that leaves
them — a `CNAME` whose target's addresses the server did not include —
ends with no addresses, and no further question is asked.

### 4.9.1.5 The reply fields

- `addresses` lists each finished task's addresses in turn, in the order
  the tasks finished, so whether IPv4 or IPv6 addresses come first
  depends on which family was answered first. [*engine-lookup.address-order-follows-completion]
- `canonical` and `source` come from the same selected `found` task.
  Prefer a task that produced addresses; when both or neither did, prefer
  `A` over `AAAA`. Completion order does not affect this choice.
  `canonical` is the name that task's CNAME chase ended at, after search
  expansion. Without a `found` task it is the name asked, as parsed, in
  presentation form without a trailing dot (the root is `.`).
  [*engine-lookup.canonical-name-rule]
- A `found` with no addresses still reports the expanded or chased name.
  [*engine-lookup.canonical-expanded-without-addresses]
- A name that does not parse is answered at once with `outcome`
  `notfound`, `canonical` `.`, no addresses and `source` `local`; no
  task is started. [*engine-lookup.unparseable-name-canonical-root]
- `source` is the source of the selected task, and `local` when no task
  was `found`. It describes that task; the other family's records can
  have a different source. [*engine-lookup.deterministic-source]
- `validation` is `unvalidated`.
- A `lookup` adds one to `queries` however many tasks it makes. [*engine-lookup.counts-one-query]

## 4.9.2 `reverse` becomes a `PTR` question [*engine-lookup.reverse-is-ptr-resolve]

A `reverse` turns its address into the reverse-mapping name — the four
octets reversed under `in-addr.arpa` for IPv4, the 32 nibbles reversed,
in lower-case hexadecimal, under `ip6.arpa` for IPv6 — and resolves that
name for `PTR` exactly as a `resolve` without `no_cache` would. The
reply is an ordinary `answer` (§5.3).

---

# 4.10 Counters

_Peios / Peios technical reference / resolvd / The Engine_

> The seven counters status reports, exactly what moves each one, and what resets them.

The engine keeps seven counters, reported by `status` (§5.3). Each is an
unsigned 64-bit count.

## 4.10.1 What each counter counts

| Counter | Goes up by one for |
|---|---|
| `queries` | Every `resolve`, `lookup` and `reverse` request that passes the access check, and every syntactically accepted stub query, including UDP budget refusals and requests whose name does not parse [*engine-counters.queries] |
| `synthetic` | Every task answered by a synthetic name (§4.2), including `.local`'s `notfound`; an `any` lookup of `localhost` adds two [*engine-counters.synthetic] |
| `cache_hits` | Every candidate found live in the cache (§4.5), including a `notfound` hit that moves on to the next candidate [*engine-counters.cache-hits] |
| `upstream_sent` | Every transaction started, UDP or TCP, including TCP retries. It is counted before the socket is opened, so a transaction whose socket cannot be opened, connected or sent on (§4.7) counts here as well as in `upstream_failed`, though nothing reached the wire [*engine-counters.upstream-sent] |
| `upstream_answered` | Every matching reply (§4.8), whatever its response code, including a truncated one [*engine-counters.upstream-answered] |
| `upstream_failed` | Every failed transaction (§4.6): a timeout, a transport failure, or a reply with a response code other than `NOERROR` and `NXDOMAIN` [*engine-counters.upstream-failed] |
| `refused` | Every question refused at the in-flight ceiling (§4.6), the UDP pending budgets (§6.1), or because fresh query entropy is unavailable (§4.7) [*engine-counters.refused] |

## 4.10.2 What is not counted [*engine-counters.status-flush-and-netd-not-counted]

`status` and `flush` requests are not counted, and nothing on the netd
channel is.

An unmatched reply changes neither upstream counter (§4.8). A later
matching reply adds one `upstream_answered`; if the original deadline
expires without one, the timeout adds one `upstream_failed` instead.
A reply with `SERVFAIL` shows up in both. A transaction that could not
be sent shows up in `upstream_sent` and `upstream_failed`, and never in
`upstream_answered`.

## 4.10.3 Resetting the counters [*engine-counters.reset-only-by-restart]

The counters start at zero when resolvd starts, and are reset only by a
restart. `flush` does not reset them.

---

# 5.1 Framing and Limits

_Peios / Peios technical reference / resolvd / The Native Socket_

> How resolvd reads a request from a native connection — the connection cap, the size ceiling, the five-second bound, decoding, and the exact error replies — and how it writes the reply.

The framing, the one-request-per-connection rule and the requirement to
answer errors with an error reply are PSPU §6.4's. This article is
resolvd's reading of them.

## 5.1.1 Accepting connections [*native-framing.pending-connection-cap-closes-silently]

At most 64 waiting connections are accepted per event-loop iteration.
When 256 native connections are either still sending their requests or
waiting for engine answers, a newly accepted connection is closed at
once, with no reply; so is one that cannot be made nonblocking. Reading a
request keeps its slot occupied until resolution completes or the asker
is abandoned (§4.1). Reply writers use a separate bounded queue (§2.5).

## 5.1.2 Reading the request

Each connection is read without blocking. Partial headers and payloads
are retained until more bytes arrive or the delivery bound expires.

1. Read exactly the four-byte little-endian payload length. A length
   above 65 536 gets the reply `request too large` and the connection
   closes, without reading any payload bytes. [*native-framing.length-ceiling]
2. Read only that many payload bytes, in chunks of at most 4 096 bytes.
   The buffered frame never exceeds 65 540 bytes, including its header. [*native-framing.buffered-bytes-ceiling]
3. Decode the complete payload immediately. Bytes after this first frame
   remain unread and do not count against its size ceiling; they cannot
   make a legal request too large. [*native-framing.trailing-bytes-ignored]

### 5.1.2.1 The length check precedes the payload [*native-framing.payload-read-before-length-check]

The declared length is checked before any payload is read, even when the
client queues the header and a large payload together. A legal request
followed by additional bytes is still answered once. This is one request
per connection; extra frames are not processed. When extra bytes remain
unread, the client may observe a connection reset after its framed reply
instead of a clean end of file.

## 5.1.3 A connection that ends early

- A connection that closes, or fails to read, before its request is
  complete is dropped without a reply. [*native-framing.early-close-dropped]
- A client that sends its whole request and then shuts down its writing
  side is answered as usual, whether the end of file is already waiting
  behind the request or arrives later. The complete frame is decoded
  without reading beyond it, and the connection closes after its one
  reply. A partial frame followed by end of file is dropped immediately,
  without waiting for the delivery timeout.
  [*native-framing.request-then-half-close-dropped]
- A connection that has not delivered a complete request five seconds
  after it was accepted is closed without a reply. [*native-framing.five-second-delivery-bound]

## 5.1.4 Decoding

The payload is a MessagePack map. The following describes the decoder
in resolvd 0.1.6 at source `b4f7085`, before the proposed source
correction described below:

- Every key is a string, and a key repeated in the request's top-level
  map is an error. Keys inside a skipped value are not checked for
  repeats. [*native-framing.duplicate-top-level-key-is-error]
- A key resolvd does not know is skipped when its value is `nil`, a
  boolean, an integer, a string, binary, an array or a map, with one
  exception among the integers: an integer above
  9 223 372 036 854 775 807 (a `uint 64` of 2^63 or more) anywhere
  inside the value, a map key included, fails the request with
  `malformed message: unexpected value type`. [*native-framing.unknown-keys-skipped]
- A floating-point or extension value anywhere inside an unknown key's
  value fails the decode, and so does a string inside it that is not
  UTF-8: a skipped string is still checked. [*native-framing.unknown-key-unsupported-value-fails]
- An array or map nested more than 32 levels deep inside an unknown
  key's value, counting the value itself as the first level, fails the
  decode. [*native-framing.unknown-key-nesting-limit]
- `query` names the request; a missing `query` is an error, and an
  unknown one is an error. [*native-framing.missing-or-unknown-query-is-error]
- Every key resolvd knows — `query`, `name`, `type`, `no_cache`,
  `family` and `address` — is read and type-checked in every request,
  whether that request takes it or not, with the types of PSPU §6.5. A
  value of the wrong type is an error, so `{query: status, name: 5}` is
  refused; `type` is checked against 65 535 in every request, so
  `{query: status, type: 70000}` is refused too. `family` takes any
  string (§5.3), and `address` is parsed as an address only in a
  `reverse`. [*native-framing.wrong-field-type-is-error]
- Anything after the top-level map in the payload is ignored. [*native-framing.bytes-after-map-ignored]

The map is read in order, and the first error met is the one reported.
A missing field is looked for only after the whole map has been read.

### 5.1.4.1 Proposed unknown-field correction [*native-framing.proposed-unknown-field-correction]

The proposed source correction makes `libresolv` skip floating-point
values, fixed and variable-length extension values (including their
type byte), and the entire unsigned 64-bit range in unknown fields.
This applies to requests and replies, including unknown `status`
counter keys and values nested inside skipped arrays and maps. It does
not change the 0.1.6 source behavior described above.

Skipped strings must still be UTF-8, skipped containers retain the
32-level nesting limit, and truncated values still fail. The reserved
MessagePack marker `0xc1` still produces `unsupported value type`.
Known fields retain their existing type and range checks; in particular,
this does not extend the range accepted by a known integer field.

## 5.1.5 Error replies

Every error reply is `ok: false` with an `error` string and nothing
else. The causes below describe the same source version before the
proposed correction;
after that change, unknown float, extension and large unsigned integer
values no longer produce the two type errors:

| `error` | Cause |
|---|---|
| `request too large` | The size checks above |
| `malformed message: truncated message` | The payload ends inside a value, or an array or map claims more items than bytes remain, a map's keys and values counted separately [*native-framing.error-truncated] |
| `malformed message: unexpected value type` | A known field, a key, or the payload itself has the wrong MessagePack type; this includes a `type` that is negative or above 9 223 372 036 854 775 807, and an integer above 9 223 372 036 854 775 807 anywhere inside an unknown key's value [*native-framing.error-unexpected-type] |
| `malformed message: unsupported value type` | A floating-point or extension value [*native-framing.error-unsupported-type] |
| `malformed message: string is not UTF-8` | A string that is not UTF-8 [*native-framing.error-not-utf8] |
| `malformed message: nested too deeply` | Nesting beyond 32 levels inside a skipped value [*native-framing.error-nested-too-deeply] |
| `missing or malformed field <name>` | A field the request needs is absent; a `type`, in any request, from 65 536 to 9 223 372 036 854 775 807; or the `address` of a `reverse` that is not an IP address [*native-framing.error-missing-or-malformed-field] |
| `duplicate field <name>` | A repeated key [*native-framing.error-duplicate-field] |
| `unknown query "<name>"` | A `query` resolvd does not know [*native-framing.error-unknown-query] |
| `access denied` | The access check (§5.2) |
| `reply too large` | The encoded reply would exceed the 65 536-byte payload ceiling; it is replaced by this error |

### 5.1.5.1 The connection after an error reply [*native-framing.connection-closed-after-error-reply]

After an error reply the connection is closed.

## 5.1.6 Writing the reply

- The reply is framed the same way — four-byte little-endian length,
  then the MessagePack map — and written without blocking. An unfinished
  write is queued with a fixed one-second deadline and a cap of 256 queued
  native replies (§2.5). A failed or expired write is logged as
  `control: reply failed: <error>`. The connection is then closed. [*native-framing.reply-written-then-closed]
- An encoded reply payload may be at most 65 536 bytes, excluding its
  four-byte length prefix. A larger reply is replaced by the error
  `reply too large`; no partial answer is sent. This applies to every native
  reply kind. Exactly 65 536 bytes is allowed.
  [*native-framing.reply-size-not-checked]

---

# 5.2 The Access Check

_Peios / Peios technical reference / resolvd / The Native Socket_

> How resolvd checks each native request against its control object — the peer's token, the generic mapping, the compiled default descriptor — and when a new descriptor takes effect.

The control object and its check are PSPU §6.4's. resolvd makes the
check after the request has been decoded and before anything else is
done with it.

## 5.2.1 The check

1. The connecting peer's token is opened from the socket. When it
   cannot be, resolvd logs `control: no peer token: <error>` and the
   request is denied. [*native-access.no-peer-token-is-denial]
2. A KACS access check is made of the request's right against the
   control object's descriptor, with that token. A check that cannot be
   completed counts as a denial. [*native-access.failed-check-is-denial]
3. A denied request is answered `access denied` (§5.1). [*native-access.denial-reply]

| Request | Right |
|---|---|
| `resolve`, `lookup`, `reverse`, `status` | `RESOLVER_QUERY` (`0x0001`) [*native-access.query-right-requests] |
| `flush` | `RESOLVER_CONTROL` (`0x0002`) [*native-access.control-right-requests] |

### 5.2.1.1 The generic mapping [*native-access.generic-mapping]

The check uses this generic mapping:

| Generic right | Maps to |
|---|---|
| `GENERIC_READ` | `RESOLVER_QUERY`, `READ_CONTROL` |
| `GENERIC_WRITE` | `RESOLVER_CONTROL`, `READ_CONTROL` |
| `GENERIC_EXECUTE` | `RESOLVER_QUERY` |
| `GENERIC_ALL` | `RESOLVER_ALL_ACCESS` (`0x000F0003`) |

### 5.2.1.2 Decoding comes before the check [*native-access.decode-before-check]

A request is decoded before it is checked, so a malformed request gets
its decoding error whoever sent it, and an unknown `query` gets
`unknown query` rather than `access denied`.

### 5.2.1.3 The answer does not depend on the caller [*native-access.answer-independent-of-caller]

The peer's identity is used for this check and for nothing else. Every
caller allowed to ask gets the same answer to the same question.

## 5.2.2 The compiled default descriptor [*native-access.compiled-default-descriptor]

The descriptor is `ControlSecurity` when it holds a valid self-relative
descriptor, else the compiled default (§2.3):

| Component | Value |
|---|---|
| Owner | SYSTEM |
| Group | SYSTEM |
| DACL | SYSTEM: `RESOLVER_ALL_ACCESS`; Administrators: `RESOLVER_ALL_ACCESS`; Everyone: `RESOLVER_QUERY` and `READ_CONTROL` |

## 5.2.3 A new descriptor applies to later requests [*native-access.new-descriptor-applies-to-later-requests]

The descriptor is rebuilt whenever the registry configuration changes
(§2.3). A request decoded after the change is checked against the new
one; a request already being answered is not checked again.

## 5.2.4 What the control object does not govern [*native-access.stub-not-governed]

The stub listener is not checked against the control object (PSPU §6.8):
anything on the machine that can reach `127.0.0.53` can ask. A program
denied `RESOLVER_QUERY` on the native socket still resolves names
through the stub listener. The NSS shim uses the native socket, so for
such a program `getaddrinfo` fails (§7.1) while a resolver that speaks
DNS directly succeeds.

---

# 5.3 Requests

_Peios / Peios technical reference / resolvd / The Native Socket_

> What resolvd does with each native request — resolve, lookup, reverse, status, flush — and what it puts in each reply field.

The requests, their fields and their reply shapes are PSPU §6.5. This
article covers what resolvd accepts and what it fills in.

## 5.3.1 Common to every request

A request that passes the access check (§5.2) is handled at once:

- `status` and `flush` are answered within the same loop iteration; [*native-requests.status-and-flush-answered-at-once]
- the other three become engine questions (§4.1), and the connection is
  held until the engine answers; the reply is written then. [*native-requests.questions-held-until-answered]

## 5.3.2 `resolve`

| Field | Accepted |
|---|---|
| `name` | Any string: no `name` is refused at decoding. It is handed to the engine as it is, which parses it (§4.1). [*native-requests.resolve-name-accepted] |
| `type` | Any value from 0 to 65 535, forwarded as it is — `ANY` (255), `OPT` (41) and types resolvd has no parser for included. [*native-requests.resolve-type-forwarded-as-is] |
| `no_cache` | Boolean; absent means `false`. Skips the cache lookup (§4.5). [*native-requests.resolve-no-cache-default-false] |

The reply's `outcome`, `source`, `server`, `interface` and `rcode` are
as §4.1 describes. Each record carries:

| Key | Content |
|---|---|
| `name` | The owner name in presentation form, without a trailing dot; the root is `.`. A dot or backslash inside a label is written `\.` or `\\`, and any byte outside `!` to `~` as a backslash and three decimal digits. [*native-requests.record-name-presentation] |
| `type` | The record type number |
| `ttl` | As sent by the server, lowered on a cache hit (§4.5); 0 for a synthetic record |
| `data` | The record data in uncompressed wire form [*native-requests.record-data-uncompressed-wire] |
| `text` | The record data in presentation form (below) [*native-requests.record-text-forms] |

| Type | `text` |
|---|---|
| `A`, `AAAA` | The address |
| `CNAME`, `PTR`, `NS` | The target name, written as `name` is |
| `SOA` | `mname rname serial refresh retry expire minimum` |
| `MX` | `preference exchange` |
| `SRV` | `priority weight port target` |
| `TXT` | Each string in double quotes, separated by spaces; inner quotes are escaped as `\"`, backslashes are not escaped, and bytes that are not UTF-8 become U+FFFD |
| Anything else | `\# <length> <hex>`, the hex in lower case |

## 5.3.3 The `lookup` family [*native-requests.lookup-unknown-family-is-any]

`family` takes `any`, `inet` or `inet6`. Absent, or any other string,
means `any`. The reply is built as §4.9 describes.

## 5.3.4 The `reverse` address [*native-requests.reverse-bad-address-is-error]

`address` is an IPv4 or IPv6 address in textual form; anything else is
the error `missing or malformed field address`. The question is built as
§4.9 describes.

## 5.3.5 `status`

| Key | Content |
|---|---|
| `hostname` | The hostname in use (§3.3), or an empty string when there is none [*native-requests.status-hostname] |
| `netd` | Whether the netd channel is connected at this moment [*native-requests.status-netd] |
| `scopes` | Every scope in the latest snapshot, in snapshot order — including scopes with no servers and scopes that take no part in routing [*native-requests.status-scopes-every-scope] |
| `fallback_servers` | `FallbackServers` as parsed (§2.3) [*native-requests.status-fallback-servers] |
| `cache_entries` | The number of entries held, expired ones included (§4.5) |
| `counters` | §4.10 |

### 5.3.5.1 Each scope in `status`

Each scope carries `interface` (the interface name), `servers` and
`domains` as parsed from the snapshot (§3.3), `default_route`,
`exclusive`, `metric`, `subnets`, and `demoted`:

- `subnets` holds the interface's addresses as `address/prefix` — the
  address itself, not the network it is in; [*native-requests.status-subnets-are-interface-addresses]
- `demoted` holds those of the scope's servers that are demoted at this
  moment (§4.6); [*native-requests.status-demoted-servers]
- a scope's `level` is not reported. [*native-requests.status-level-not-reported]

## 5.3.6 `flush`

- Every cache entry, for every scope, is discarded, resolvd logs
  `cache flushed` at info level, and the reply is `ok: true` with no
  `kind`. [*native-requests.flush-discards-everything]
- Demotions and counters are left as they are. [*native-requests.flush-keeps-demotions-and-counters]

---

# 6.1 Receiving Queries

_Peios / Peios technical reference / resolvd / The Stub Listener_

> How the stub listener reads queries over UDP and TCP — source checks, connection limits, the per-connection bound, the queries it refuses at the door, and what it ignores in a query it accepts.

The stub listener's address, its loopback-only rule and its rendering
are PSPU §6.8. This article covers how resolvd reads queries; the
replies are §6.2.

## 6.1.1 UDP

When the UDP socket is readable, at most 64 datagrams are read per
event-loop iteration, including malformed or dropped datagrams. For each:

1. A datagram whose source address is not a loopback address — anything
   outside `127.0.0.0/8` — is dropped without a reply. [*stub-receive.udp-non-loopback-source-dropped]
2. It is checked as a query (below). A refused query gets its error
   reply, or nothing.
3. A valid query is admitted if fewer than 16 UDP questions from its
   source address **and port**, and fewer than 256 UDP questions in total,
   are waiting for engine answers. Otherwise it gets `SERVFAIL`, with no
   upstream transaction, and increments both `queries` and `refused`.
   This admission check precedes synthetic and cache lookup.
   [*stub-receive.udp-pending-budgets]
4. An admitted query becomes an engine question (§4.1). Its slot is
   released when the engine completes it, including timeout completion.

The endpoint is an accounting key, not an authenticated process identity.
Changing source ports can consume the aggregate UDP allowance but cannot
consume the separate native or TCP admission allowances. These bounds
limit retained work; they do not promise fairness between local processes.

### 6.1.1.1 The UDP read buffer [*stub-receive.udp-read-buffer-4096]

Each datagram is read into a 4 096-byte buffer; a longer datagram is
truncated by the read to its first 4 096 bytes.

### 6.1.1.2 A UDP query longer than the buffer [*stub-receive.udp-oversized-query-formerr]

A query longer than 4 096 bytes therefore arrives cut short. When the
cut falls inside the DNS message, the message no longer decodes and,
being at least 12 bytes long, is answered `FORMERR` (below). Bytes after
the end of the message are ignored by decoding, so a query whose message
fits in 4 096 bytes and is only padded beyond them is answered as usual.

### 6.1.1.3 Where UDP replies go [*stub-receive.udp-reply-to-source]

Replies are sent to the source address and port the query came from.

## 6.1.2 TCP

When the TCP listener is readable, at most 64 waiting connections are
accepted per event-loop iteration.
A connection is closed at once, without a reply:

- when 256 stub TCP connections are still sending queries or waiting for engine answers; [*stub-receive.tcp-pending-cap-256]
- when its source is not a loopback address. [*stub-receive.tcp-non-loopback-source-closed]

A connection that cannot be made nonblocking is closed the same way.

### 6.1.2.1 Reading a TCP query

Each held connection is read as it becomes readable, everything
available each time, in 4 096-byte chunks. A message is a two-byte
big-endian length and that many bytes.

- When more than 65 537 bytes are buffered, the connection is closed
  without a reply. [*stub-receive.tcp-buffer-ceiling]
- When a whole message has arrived, it is checked as a query, and the
  connection leaves the set of connections still sending, but continues
  to occupy its admission slot while the engine works.
- A connection carries one query. Anything sent after the first message
  is ignored, and the connection is closed once the answer, or the
  error reply, has been written. [*stub-receive.tcp-one-query-per-connection]
- A connection that closes, or fails to read, before its message is
  whole is closed without a reply.
- A client that sends a complete query and then shuts down its writing
  side is still answered. This also applies when the read finds end of
  file immediately after the buffered query. The connection closes
  after its one reply. A partial frame followed by end of file is dropped
  immediately, without waiting for the delivery timeout.
  [*stub-receive.tcp-query-then-half-close-dropped]
- A connection that has not delivered a whole message ten seconds after
  it was accepted is closed without a reply. [*stub-receive.tcp-ten-second-delivery-bound]
- Once its query has arrived the ten-second bound no longer applies; the
  connection is held until the engine answers or a definite hangup/error
  abandons its question (§4.1). Reply writers use a separate bounded queue
  (§2.5). [*stub-receive.tcp-bound-ends-once-query-arrives]

## 6.1.3 Checking a query

| The message | Result |
|---|---|
| Does not decode, and is at least 12 bytes long | `FORMERR`: the query's ID, `QR` set, every other header bit clear, no sections [*stub-receive.undecodable-long-message-formerr] |
| Does not decode, and is shorter than 12 bytes | Dropped without a reply [*stub-receive.undecodable-short-message-dropped] |
| Has `QR` set | Dropped without a reply [*stub-receive.response-dropped] |
| Has an opcode other than `QUERY` | `NOTIMP` [*stub-receive.non-query-opcode-notimp] |
| Has no question, or more than one | `FORMERR` [*stub-receive.question-count-formerr] |
| Has a question class other than `IN` | `NOTIMP`; the question is echoed unchanged and no upstream query is made [*stub-receive.unsupported-class-notimp] |
| Otherwise | Accepted |

### 6.1.3.1 The `NOTIMP` and question-count `FORMERR` replies

These two replies are built from the query:

- the header copies the query's ID, opcode, `RD` and `CD` bits, and
  sets `QR` and `RA`; every other bit is clear; [*stub-receive.error-reply-header]
- the question section is the query's, whatever it holds; [*stub-receive.error-reply-question]
- when the query carried an `OPT` record, the reply carries one whose
  payload size is the size the query advertised, with `DO` clear and no
  options; without one, the reply has none. [*stub-receive.error-reply-opt-size]

## 6.1.4 What an accepted query contributes

- Only `IN` questions are accepted. Their decoded name and type reach the
  engine; unsupported classes are rejected at the stub door over both
  UDP and TCP.
- `RD`, `CD`, the `DO` bit and any EDNS options are not looked at. [*stub-receive.flags-and-edns-options-ignored]
- Stub queries always consult the cache (§4.5).

### 6.1.4.1 Wire names

The decoded name reaches the engine directly, without conversion to
presentation text and reparsing. Label boundaries and bytes, including
embedded dots, backslashes, NUL and other binary bytes, are preserved.
The usual upstream ASCII case randomization still applies (§4.3).
[*stub-receive.wire-labels-preserved]

Names are subject to DNS wire limits, not the length of a displayed escape
sequence. A valid 63-byte binary label or a valid name whose display form
would exceed 255 bytes can therefore be resolved normally.
[*stub-receive.wire-lengths-preserved]

Search expansion (§4.1) uses the number of wire labels: a single label
containing a literal dot remains a single-label name.

## 6.1.5 Which stub queries are counted [*stub-receive.refused-queries-not-counted]

Every valid query counts in `queries` (§4.10), including a UDP query
refused by the pending budgets (which also increments `refused`). Queries
rejected by syntax, opcode, question count or class checks do not count.

---

# 6.2 Rendering Replies

_Peios / Peios technical reference / resolvd / The Stub Listener_

> How an engine answer becomes a DNS reply at the stub listener — header bits, the CNAME for an expanded name, negative replies, EDNS, and truncation.

The outcome-to-response-code table and the rules for `CNAME`, `RA`, `AA`
and `AD` are PSPU §6.8. This article is how resolvd builds each reply.

## 6.2.1 The header and question

- The reply copies the query's ID, opcode, `RD` and `CD` bits and its
  question section. [*stub-render.copied-header-fields]
- `QR` and `RA` are set. `AA`, `AD` and `TC` are clear, except that `TC`
  is set on a truncated UDP reply (below). [*stub-render.qr-ra-set-aa-ad-clear]
- No bit from an upstream server's reply reaches the stub reply. [*stub-render.no-upstream-bits]

## 6.2.2 By outcome

### 6.2.2.1 `found` replies

- The response code is `NOERROR`, and the answer section holds the
  engine's records in the order the engine has them (§4.8). [*stub-render.found-records-in-engine-order]
- When the answer was found at a candidate other than the question's
  name — compared case-insensitively — the section begins with a
  `CNAME` from the question's name, in
  the query's case, to the candidate. Its TTL is the least TTL among the
  records when any are present. [*stub-render.expansion-cname]
- A `found` with no records at an expanded name is `NOERROR` with only
  that leading `CNAME`. Its TTL is the negative cache lifetime: the lesser
  of the enclosing upstream `SOA` record's TTL and minimum field, capped at
  300 seconds and at the TTL of any `CNAME` path the reply followed (§4.5).
  A cache hit uses the remaining lifetime, rounded down to whole seconds.
  With no enclosing `SOA`, the TTL is zero; no lifetime is invented. The
  authority section remains empty. An unexpanded empty `found` has no
  `CNAME`. [*stub-render.expanded-nodata-has-no-cname]

### 6.2.2.2 `notfound` replies [*stub-render.nxdomain-without-soa]

`NXDOMAIN`, with empty answer and authority sections. No `SOA` is
supplied, so a client has nothing from which to cache the absence.

### 6.2.2.3 `unavailable` replies [*stub-render.servfail-empty]

`SERVFAIL`, with empty sections.

## 6.2.3 EDNS in stub replies [*stub-render.opt-echoed-with-1232]

When the query carried an `OPT` record, the reply carries one
advertising a payload size of 1 232 bytes, with `DO` clear and no
options. A query without `OPT` gets a reply without one.

## 6.2.4 Size and truncation

- Over UDP the reply's size limit is the payload size the query's `OPT`
  advertised, or 512 bytes when that is smaller or there is no `OPT`. [*stub-render.udp-size-limit]
- A reply over the limit is replaced by a truncated one, which keeps the
  same header with `TC` set, and the question. [*stub-render.truncated-reply-header-and-question]
- The truncated reply keeps the `OPT` record when the reply has one. [*stub-render.truncated-reply-keeps-opt]
- The truncated reply carries no records: nothing is partially
  included. [*stub-render.truncated-reply-has-no-records]
- A reply that cannot be encoded at all is not sent. [*stub-render.unencodable-reply-not-sent]
- Over TCP the whole reply is sent, whatever its size up to the 65 535
  bytes a DNS message can hold. [*stub-render.tcp-sends-whole-reply]
- A TCP reply is written with its two-byte length, without blocking.
  Unfinished writes are queued with a fixed one-second deadline and a cap
  of 256 queued TCP replies (§2.5); a failed or expired write is not logged. [*stub-render.tcp-write-failure-not-logged]

---

# 7.1 The Module

_Peios / Peios technical reference / resolvd / The Nss Shim_

> libnss_peios_net.so.2 as built — what it links, how each call talks to resolvd, its timeout, and the status, errno and h_errno it returns for every result.

The shim's obligations are PSPU §6.10. This chapter describes the module
resolvd's source tree builds.

## 7.1.1 The object

- `libnss_peios_net.so.2` is installed by `dev.peios.resolvd-nss` at
  `/usr/lib/x86_64-linux-peios/libnss_peios_net.so.2`. [*nss-module.installed-path]
- It is built from the `nss` crate, with the native channel codec
  compiled in. Its only shared-object dependencies are `libc.so.6` and
  the dynamic loader, `ld-linux-x86-64.so.2`. The GCC unwinder required by
  Rust is linked statically, with its symbols private to the module; name
  lookups do not load `libgcc_s.so.1`. The identity shim, `libnss_peios.so.2`,
  uses the same static-unwinder arrangement. Neither shim links libpeios. [*nss-module.link-dependencies]

### 7.1.1.1 The entry points [*nss-module.entry-points]

The module exports six entry points:

| Entry point | Asks |
|---|---|
| `_nss_peios_net_gethostbyname4_r` | `lookup`, family `any` |
| `_nss_peios_net_gethostbyname3_r` | `lookup`, family from `af` |
| `_nss_peios_net_gethostbyname2_r` | as `gethostbyname3_r`, with no TTL or canonical name out |
| `_nss_peios_net_gethostbyname_r` | as `gethostbyname3_r`, with `af` `AF_INET` |
| `_nss_peios_net_gethostbyaddr2_r` | `reverse` |
| `_nss_peios_net_gethostbyaddr_r` | as `gethostbyaddr2_r`, with no TTL out |

## 7.1.2 One connection per call

- Every call that needs resolvd opens a new connection to
  `/run/resolvd/resolv.sock`, sends one request, reads one reply, and
  closes the connection. [*nss-module.one-connection-per-call]
- Nothing is kept between calls: no connection, no cache, no
  configuration. The module reads no file. [*nss-module.no-state-no-files]

### 7.1.2.1 The ten-second socket timeouts [*nss-module.ten-second-socket-timeouts]

Each connection is given ten-second read and write timeouts, which
apply to each read and write on the socket. Ten seconds is longer than
the six seconds a question's three timed-out attempts take in resolvd,
TCP retries included (§4.6), so a slow upstream normally reaches the
caller as `unavailable` from resolvd rather than as a timeout here. A
question can still reach the timeout when many search candidates each
answer `NXDOMAIN` slowly, since those answers do not spend the attempt
budget; that is reported as the table below shows.

## 7.1.3 Results

| Result | NSS status | `errno` | `h_errno` |
|---|---|---|---|
| Addresses, or names, found | `SUCCESS` | — | — [*nss-module.found-is-success] |
| `found`, but no address of the asked family | `NOTFOUND` | `ENOENT` | `HOST_NOT_FOUND` [*nss-module.found-without-family-is-host-not-found] |
| `notfound` | `NOTFOUND` | `ENOENT` | `HOST_NOT_FOUND` [*nss-module.notfound-is-host-not-found] |
| `unavailable` | `TRYAGAIN` | `EAGAIN` | `TRY_AGAIN` [*nss-module.unavailable-is-try-again] |
| No reply within ten seconds | `TRYAGAIN` | `EAGAIN` | `TRY_AGAIN` [*nss-module.timeout-is-try-again] |
| resolvd cannot be connected to | `UNAVAIL` | `ENOENT` | `NO_RECOVERY` [*nss-module.unreachable-is-unavail] |
| An error reply, including `access denied` | `UNAVAIL` | `ENOENT` | `NO_RECOVERY` [*nss-module.error-reply-is-unavail] |
| A reply of the wrong kind, a reply that does not decode, or any other I/O error | `UNAVAIL` | `ENOENT` | `NO_RECOVERY` [*nss-module.other-failures-are-unavail] |
| The caller's buffer is too small | `TRYAGAIN` | `ERANGE` | `NETDB_INTERNAL` (`-1`), required for retry; see version note below [*nss-module.small-buffer-is-erange] |
| `gethostbyname3_r` or `2_r` with an `af` other than `AF_INET` or `AF_INET6` | `UNAVAIL` | `EAFNOSUPPORT` | `NO_DATA` [*nss-module.unsupported-family-forward] |
| `gethostbyaddr2_r` or `_r` with an `af` and length other than `AF_INET`/4 or `AF_INET6`/16 | `UNAVAIL` | `EAFNOSUPPORT` | `NO_RECOVERY` [*nss-module.unsupported-family-reverse] |
| A null name, a name that is not UTF-8, or a null address | `NOTFOUND` | `ENOENT` | `HOST_NOT_FOUND` [*nss-module.null-or-non-utf8-input-is-notfound] |

> [!NOTE]
> The buffer-exhaustion row specifies the glibc-compatible retry contract.
> [resolvd 0.1.6 source](https://github.com/peios/resolvd/blob/9c96d693c9672e635cc40ee1e2ea8122e64f017e/nss/src/lib.rs)
> instead sets `h_errno` to `0`; that implementation does not satisfy the
> retry contract. The corrected behavior requires the
> [NSS buffer-retry fix](https://github.com/peios/resolvd/pull/1).
> Check the installed module's source revision before relying on it.

The missing-family row above describes
[source `b4f7085`](https://github.com/peios/resolvd/blob/b4f70857729069952762f8e2a2b56357b15d4760/nss/src/lib.rs):
a found name with no address of the family asked is reported exactly as
a name that does not exist. This does not meet PSPU §6.10's requirement
that `found` without an address of that family return `NO_DATA`.

The [proposed source correction](https://github.com/peios/resolvd/blob/6d23bf920c60e3695781fc960e292a139d77b044/nss/src/lib.rs) preserves
`NOTFOUND` and `ENOENT`, but sets `h_errno` to `NO_DATA` when a `found`
reply has no usable address of the requested family. A `found` reverse
reply with no `PTR` record has the same result. Explicit `notfound`
still returns `HOST_NOT_FOUND`; `unavailable` and transport failures keep
their distinct results in the table. This describes proposed source
behavior, not a released package or an installed module. Check the
installed module's source revision before relying on this distinction.
[*nss-module.found-without-family-is-no-data]

### 7.1.3.1 `AF_UNSPEC` [*nss-module.af-unspec-only-via-gethostbyname4]

`AF_UNSPEC` is accepted only by `gethostbyname4_r`, which always asks
for both families.

## 7.1.4 The caller's buffer [*nss-module.results-placed-in-caller-buffer]

Every string, pointer array and address the module returns is placed in
the buffer glibc passes in; the module allocates nothing that outlives
the call. When the buffer cannot hold the result, the call returns as in
the table, and nothing written to the buffer is valid.

---

# 7.2 Forward Lookups

_Peios / Peios technical reference / resolvd / The Nss Shim_

> How the shim answers a host name — localhost in-process, everything else through a lookup — and how the reply is laid out as gaih_addrtuple and hostent.

## 7.2.1 `localhost` in the shim

- A name that, with trailing dots removed, is `localhost` or ends in
  `.localhost`, compared case-insensitively, is answered inside the
  calling process without contacting resolvd — whether resolvd is
  running or not. [*nss-forward.localhost-answered-in-process]
- The answer is `127.0.0.1` for IPv4, `::1` for IPv6, or both, IPv4
  first, with canonical name `localhost` and TTL 0. [*nss-forward.localhost-answer]

## 7.2.2 Other names

- Any other name is sent as a `lookup` (§4.9), with the family the
  entry point asks for. The addresses in the reply are filtered to that
  family, and an empty result is `NOTFOUND` (§7.1). [*nss-forward.addresses-filtered-to-family]
- Addresses are returned in the order resolvd's reply lists them. [*nss-forward.reply-order-kept]

resolvd does not fix which family comes first (§4.9); glibc's own
sorting of `getaddrinfo` results is what callers normally see.

## 7.2.3 `gethostbyname4_r`

The result is a linked list of `gaih_addrtuple`, and the pointer glibc
passes is overwritten with the head of the list:

| Field | Content |
|---|---|
| `next` | The next tuple: one tuple per address, in reply order [*nss-forward.gaih-addrtuple-order] |
| `name` | One copy of the canonical name, shared by every tuple [*nss-forward.gaih-addrtuple-name-shared] |
| `family` | `AF_INET` or `AF_INET6`, per address [*nss-forward.gaih-addrtuple-family] |
| `addr` | The address |
| `scopeid` | 0, so an IPv6 link-local address is returned without the interface it belongs to [*nss-forward.gaih-addrtuple-scopeid-zero] |

### 7.2.3.1 The `gethostbyname4_r` TTL [*nss-forward.gethostbyname4-ttl-least]

When `ttlp` is given it receives the least TTL among the addresses.

## 7.2.4 The forward `hostent` [*nss-forward.hostent-layout]

`gethostbyname3_r`, `2_r` and `_r` return a `hostent`:

| Field | Content |
|---|---|
| `h_name` | The reply's `canonical` name |
| `h_aliases` | Empty |
| `h_addrtype` | The `af` asked for |
| `h_length` | 4 or 16 |
| `h_addr_list` | Every address, in reply order |

## 7.2.5 The `gethostbyname3_r` TTL and canonical name [*nss-forward.gethostbyname3-ttl-and-canon]

When `ttlp` is given it receives the least TTL among the addresses, and
when `canonp` is given it is pointed at `h_name`.

---

# 7.3 Reverse Lookups

_Peios / Peios technical reference / resolvd / The Nss Shim_

> How the shim answers an address — loopback in-process, everything else through a reverse — and which records become h_name and h_aliases.

## 7.3.1 Loopback addresses in the shim [*nss-reverse.loopback-answered-in-process]

An address in `127.0.0.0/8`, or `::1`, is answered inside the calling
process without contacting resolvd: `h_name` is `localhost`, with no
aliases and TTL 0.

## 7.3.2 Other addresses

- Any other address is sent as a `reverse` (§4.9). A `found` reply with
  no records is `NOTFOUND` (§7.1). [*nss-reverse.found-without-records-is-notfound]
- In [source `b4f7085`](https://github.com/peios/resolvd/blob/b4f70857729069952762f8e2a2b56357b15d4760/nss/src/lib.rs),
  a reply whose records include no `PTR` record — only `CNAME`s, for
  instance — is `NOTFOUND` with `HOST_NOT_FOUND`. [*nss-reverse.no-ptr-record-is-notfound]

The [proposed source correction](https://github.com/peios/resolvd/blob/6d23bf920c60e3695781fc960e292a139d77b044/nss/src/lib.rs) returns
`NOTFOUND` / `ENOENT` / `NO_DATA` for a `found` reply with no records or
no `PTR` record. Explicit `notfound` remains `HOST_NOT_FOUND`; this does
not turn transient failures into negative answers. The source and
installed-module qualification in §7.1 applies here too.

## 7.3.3 The reverse `hostent` [*nss-reverse.hostent-layout]

Otherwise the reply's `PTR` records are taken in order, each record's
`text` with any trailing dots removed:

| Field | Content |
|---|---|
| `h_name` | The first `PTR` record's name |
| `h_aliases` | The remaining `PTR` records' names |
| `h_addrtype` | The `af` asked for |
| `h_length` | 4 or 16 |
| `h_addr_list` | The address asked about, alone |

## 7.3.4 The reverse TTL [*nss-reverse.ttl-least-of-all-records]

When `ttlp` is given it receives the least TTL among all the reply's
records, `PTR` or not.

---

# 8.1 The resolv Command

_Peios / Peios technical reference / resolvd / The Resolv Command_

> The operator command — its verbs and arguments, the request each sends, its output formats, and its exit statuses.

`resolv` (`/usr/bin/resolv`, package `dev.peios.resolv`) owns no state.

## 8.1.1 One request per verb [*resolv.one-request-per-verb]

Every verb but `version` is one request on the native socket, on one
connection, and its output is a rendering of the reply.

## 8.1.2 Verbs

| Command | Request |
|---|---|
| `resolv status` | `status` |
| `resolv query <name> [<type>] [--no-cache]` | `resolve` |
| `resolv lookup <name>` | `lookup`, family `any` |
| `resolv reverse <address>` | `reverse` |
| `resolv flush` | `flush` |
| `resolv version`, `resolv --version` | none: prints `resolv <version>` [*resolv.version-needs-no-daemon] |

### 8.1.2.1 The command line

- `--no-cache` is recognised anywhere on the command line and removed
  before the rest is matched; it affects only `query`. [*resolv.no-cache-anywhere]
- Any other argument list prints a usage line to standard error and
  exits 64. [*resolv.usage-exit-64]

### 8.1.2.2 Arguments checked before contacting resolvd

- The `<type>` of `query` is `A` when omitted. It is one of `A`, `NS`,
  `CNAME`, `SOA`, `PTR`, `MX`, `TXT`, `AAAA`, `SRV` or `ANY`, in any
  case, or `TYPE<n>` for any type number from 0 to 65 535. [*resolv.query-type-names]
- Any other `<type>` prints `resolv: unknown record type "<type>"` and
  exits 1 without contacting resolvd. [*resolv.unknown-type-exits-1]
- The `<address>` of `reverse` is checked in the same way: anything
  that is not an IPv4 or IPv6 address prints
  `resolv: "<address>" is not an address` and exits 1 without
  contacting resolvd. [*resolv.bad-address-exits-1]

### 8.1.2.3 No timeout in `resolv` [*resolv.no-timeout]

`resolv` sets no timeout on the connection. It waits for as long as
resolvd takes to answer.

## 8.1.3 Output

### 8.1.3.1 `query` output [*resolv.query-output]

`query` prints a summary line, then one line per record:

```text
<outcome>  <source>[ via <server>][ on <interface>]  validation <validation>
<name>	<ttl>	<type>	<text>
```

with `via` present when the reply names a server and `on` when it names
an interface, and fields on record lines separated by tabs. A line
`rcode <name>` follows the summary when the outcome is `found` and the
response code is not zero. resolvd answers `found` only with response
code zero (§4.8), so against resolvd the line does not appear.

### 8.1.3.2 `lookup` output [*resolv.lookup-output]

`lookup` prints `<outcome>  <source>  canonical <name>`, then
`<address>	<ttl>`, tab-separated, for each address.

### 8.1.3.3 `reverse` output [*resolv.reverse-output]

`reverse` prints `<outcome>  <source>`, then the `text` of every record
in the answer, whatever its type.

### 8.1.3.4 `status` output [*resolv.status-output]

`status` prints three lines:

```text
hostname   <name, or (unset)>
netd       <connected | not connected>
cache      <n> entries
```

then `scopes     (none)` when there are no scopes, or, for each scope, a
blank line and

```text
<interface>  metric <n>[  [default-route, exclusive]]
  server   <address>[  (demoted)]
  domain   <domain>
  subnet   <address/prefix>
```

with the bracketed flags present as they apply and one line per server,
domain and subnet. When fallback servers are configured, a blank line
and a `fallback   <servers>` line follow, the servers separated by single
spaces. Last come a blank line and the counters, on one line:

```text
queries <n>  synthetic <n>  cache-hits <n>  upstream sent <n> answered <n> failed <n>  refused <n>
```

### 8.1.3.5 `flush` output [*resolv.flush-silent]

`flush` prints nothing on success.

## 8.1.4 Exit status

| Status | Meaning |
|---|---|
| 0 | `found`, or `status`, `flush` or `version` succeeded [*resolv.exit-0] |
| 1 | resolvd unreachable, a protocol failure, an error reply (including `access denied`), or a bad type or address argument; a reply of the wrong kind exits 1 with no message [*resolv.exit-1] |
| 2 | `notfound` [*resolv.exit-2] |
| 3 | `unavailable` [*resolv.exit-3] |
| 64 | A command line `resolv` does not recognise |

### 8.1.4.1 The unreachable message [*resolv.unreachable-message]

Errors are printed to standard error prefixed `resolv: `. resolvd
unreachable reads
`resolv: resolvd is not reachable at /run/resolvd/resolv.sock: <error>`.

---

# 9.1 Reading resolvd's State

_Peios / Peios technical reference / resolvd / Failure Modes_

> The signals available when names stop resolving — what resolv status, the counters, the log and each door's error shape tell you — and the log lines resolvd writes.

## 9.1.1 Where to look

| Signal | Tells you |
|---|---|
| `resolv status` | Whether netd is connected, the scopes in use, which servers are demoted, the fallback servers, cache size, counters (§5.3, §8.1) |
| `resolv query <name> --no-cache` | Where a fresh answer comes from: source, server, interface, response code (§4.1) |
| `evctl 'LOGS FROM resolvd SINCE 1h ago'` | Every log line (§2.1) |
| The exit status of `resolv` | 2 for `notfound`, 3 for `unavailable`, 1 when resolvd could not be asked (§8.1) |
| The door's own error | `NXDOMAIN` or `SERVFAIL` at the stub; `HOST_NOT_FOUND`, `TRY_AGAIN` or `NO_RECOVERY` from `getaddrinfo` (§6.2, §7.1) |

Two readings of the counters are worth knowing. `upstream_failed` rising
with `upstream_answered` flat means no matching reply is arriving: the
servers are not answering, their replies do not match (§4.8), or the
transactions cannot be sent at all (§4.7), which the log shows as
`upstream <server>: <error>` lines. Both rising together means servers
answer with failure codes. `refused` above zero means the in-flight
ceiling has been reached (§4.6).

## 9.1.2 Log lines

Every line resolvd writes, with the level it is written at:

| Line | Level | Meaning |
|---|---|---|
| `native socket: <error>` | error | Startup failed at the native socket; resolvd exits (§2.2) |
| `stub listener on 127.0.0.53:53: <error>` | error | Startup failed at port 53; resolvd exits (§2.2) |
| `poll: <error>` | error | The loop failed; resolvd exits (§2.2) |
| `could not set a descriptor on <path> (<error>); …` | error | The native socket is reachable only by SYSTEM and administrators (§2.4) |
| `removed a stale /run/resolvd/resolv.sock` | warn | A previous instance left its socket behind and this one removed it; expected on every restart of the service (§2.4) |
| `could not build a descriptor: <error>` | warn | As `could not set a descriptor …`, but before writing was attempted (§2.4) |
| `registry watch unavailable (<error>); configuration is read once` | warn | Configuration changes will not be seen (§2.3) |
| `registry watch: <error>; re-arming` | warn | The watch failed and is being replaced (§2.3) |
| `Dns FallbackServers: ignoring malformed address "<s>"` | warn | §2.3 |
| `Dns ExtraSearchDomains: ignoring malformed domain "<s>"` | warn | §2.3 |
| `Dns Hosts: ignoring malformed name "<s>"` | warn | §2.3 |
| `Dns Hosts: ignoring malformed address "<s>"` | warn | §2.3 |
| `ControlSecurity is not a valid descriptor (<error>); using the default` | warn | §2.3 |
| `readiness notify: <error>` | warn | peinit was not told resolvd is ready (§2.1) |
| `netd not reachable (<error>); retrying` | warn | First failure to reach netd (§3.2) |
| `netd refused the subscription: <message>` | warn | netd answered `subscribe` with an error (§3.1) |
| `netd sent something unreadable: <error>` | warn | §3.1 |
| `lost the netd channel; reconnecting` | warn | §3.1 |
| `upstream <server>: <error>` | warn | A transaction could not be sent (§4.7) |
| `control: no peer token: <error>` | warn | A native request was denied because its token could not be read (§5.2) |
| `control: reply failed: <error>` | warn | A native reply could not be written (§5.1) |
| `stub udp: <error>`, `stub tcp accept: <error>`, `native accept: <error>` | warn | A listener read or accept failed. Accept errors pause that listener for 250 ms before retrying; other descriptors continue to be serviced [*failure-signals.listener-errors-logged-and-survived] |
| `listening on /run/resolvd/resolv.sock and 127.0.0.53:53` | info | Startup reached readiness (§2.2) |
| `subscribed to netd` | info | §3.1 |
| `netd: <summary>` | info | A snapshot was applied (§3.3) |
| `configuration changed` | info | A registry change was applied (§2.3) |
| `cache flushed` | info | A `flush` request was carried out (§5.3) |
| `/dev/kmsg mirror unavailable (<error>): log lines go to stdout/stderr only` | warn | The kernel log mirror could not be opened, which for resolvd is always; written once, directly after the first line logged (§2.1) |

## 9.1.3 What a question logs [*failure-signals.no-per-question-logging]

Answering a question writes no log line, however it is answered —
synthetic, from the cache, by a server, or `unavailable` after timeouts —
and a timeout or a demotion writes none either. The only lines a single
question or request can cause are `upstream <server>: <error>`, for a
transaction that could not be sent (§4.7), and, for a native request,
`control: no peer token: <error>` (§5.2) and
`control: reply failed: <error>` (§5.1).

---

# 9.2 Names Do Not Resolve

_Peios / Peios technical reference / resolvd / Failure Modes_

> The common ways resolution fails while resolvd is running — no scopes, unreachable servers, single labels that never leave the machine, an exclusive scope, overload, stale answers — what each looks like and how to get back to a known state.

## 9.2.1 Everything needing the network is `unavailable`

**Looks like:** `resolv query example.com` exits 3; the stub answers
`SERVFAIL`; `getaddrinfo` returns `EAI_AGAIN`. `localhost`, the
hostname and static names still answer.

**Check `resolv status`:**

- **`netd not connected` and `scopes (none)`.** resolvd has never had a
  snapshot. Without fallback servers nothing can be asked (§4.4). Start
  netd; resolvd reconnects within ten seconds of netd's socket appearing
  (§3.2) and the first snapshot brings the scopes.
- **Scopes listed, but none has a server.** Interfaces with no servers
  take no part in routing (§4.4). Either the profiles and leases supply
  no servers, or the offered addresses or explicit zones were invalid
  (§4.7). Configure `FallbackServers`
  (§2.3), or give the profile servers.
- **Servers listed and marked `(demoted)`.** Every server's last failure
  was less than 30 seconds ago (§4.6). `upstream_failed` rising shows
  it. Questions are still sent to demoted servers, so service returns as
  soon as one answers.
- **The servers listed are link-local (`fe80::…`).** Check that the
  scope names the intended interface and that it still exists. resolvd
  sends through that interface for UDP and TCP (§4.7). Link-local servers
  belong in an interface profile, not the unscoped fallback list.

## 9.2.2 A single label is `notfound` at once

**Looks like:** `resolv query printer` exits 2 without delay, with
source `local`.

No search domain applies (§4.3). Check the `domain` lines in `status`.
A scope's domains count only while it has a server; while an exclusive
scope takes every question (§4.4), only its domains apply and
`ExtraSearchDomains` is ignored.
A single label is never sent bare, so with no domain it is answered
locally.

## 9.2.3 Names meant for the VPN go elsewhere

An exclusive scope takes every question only while it is at `addressed`
or better and has at least one server (§4.4). A VPN interface whose
servers have not arrived yet, or are missing from its profile, is not
exclusive for routing, and names go to the other interfaces' servers in
the meantime. `status` shows the VPN scope with `exclusive` and no
`server` lines.

## 9.2.4 An answer is wrong or stale

Cached answers live up to a day (§4.5). They are discarded for a scope
when its server list changes, and for everything by
`resolv flush` (§5.3), which needs `RESOLVER_CONTROL`. A static name in
`Hosts\` overrides DNS at once, with no flush (§4.2).

To see what the network says now, use
`resolv query <name> --no-cache`. It skips the cache lookup, but the
answer it gets is still stored, replacing the cached one (§4.5).

## 9.2.5 Answers are slow

Each candidate can take up to six seconds of timeouts before it is
`unavailable`, longer with truncated replies retried over TCP (§4.6).
A dead first server costs two seconds per question until it is demoted;
after that it is asked last, for 30 seconds, and the cycle repeats if
it is still dead.

A UDP reply that does not match its query — a server that does not echo
the question's case exactly, for instance — is ignored (§4.8). A later
matching reply can still answer the transaction. Only if none arrives by
the original two-second deadline does the attempt time out, demote the
server, and add to `upstream_failed` without adding to `upstream_answered`.

## 9.2.6 `refused` is rising

At least 4 096 transactions are outstanding (§4.6), and new questions that need
the network are answered `unavailable` at once. Cached and synthetic
answers are unaffected. The ceiling clears as transactions complete,
within two seconds of each being sent.

## 9.2.7 Getting back to a known state

A new resolvd discards the cache, the demotions and the counters,
re-reads the registry, and takes a fresh snapshot from netd; nothing is
persisted. `svctl restart resolvd` is enough: the new process replaces
the socket its predecessor left (§2.4).

The exception is the first start after an upgrade from a resolvd whose
DACL did not name its own account (§2.4). That older resolvd's directory
and socket shut the new one out, and it exits with `native socket:
Permission denied (os error 13)` until they are gone. Remove them once
as SYSTEM, or reboot:

```sh
svctl stop resolvd; rm -rf /run/resolvd; svctl start resolvd
```

---

# 9.3 resolvd Cannot Be Reached

_Peios / Peios technical reference / resolvd / Failure Modes_

> What each door does when resolvd is not running, will not start, or refuses a caller — and the failures that leave one door working and another not.

## 9.3.1 resolvd is not running

| Door | What a client sees |
|---|---|
| NSS shim | `localhost` and loopback reverses still answer; everything else is `UNAVAIL` with `NO_RECOVERY` (§7.1) |
| Stub listener | Nothing listens on `127.0.0.53`; UDP queries go unanswered or are refused by the kernel, TCP connections are refused |
| `resolv` | Exit 1, `resolv: resolvd is not reachable at /run/resolvd/resolv.sock: <error>` |

peinit restarts resolvd whenever it exits (§2.1). The new process removes
the socket the old one left and binds a fresh one (§2.4), so an outage
after a crash lasts only until the restart completes. If resolvd keeps
exiting, the cause is one of the startup failures below.

## 9.3.2 resolvd will not start

The startup failures are fatal and logged before the exit (§2.2):

- **`stub listener on 127.0.0.53:53: <error>`:** either the port
  reservation for `tcp,udp:53` is not in the registry — its seed is
  inert until the image applies it (§2.1) — or something else already
  holds `127.0.0.53:53`. The error text says which.
- **`native socket: <error>`:** `/run/resolvd` could not be created or
  given its mode, a stale socket file could not be removed, or the
  socket could not be bound or set up.

Because the restart policy is Always, a failure that persists is a
restart loop: the same line in the log each time.

## 9.3.3 The native door is closed to ordinary programs

**Looks like:** `getaddrinfo` fails with `NO_RECOVERY` for every name
but `localhost`, for every user but SYSTEM and administrators, while
programs that send DNS to `127.0.0.53` themselves work.

Two causes:

- The socket's descriptor could not be written at startup (§2.4), and
  the log has the `could not set a descriptor` line. Ordinary programs
  cannot reach the socket at all.
- `ControlSecurity` holds a valid descriptor that does not grant
  `RESOLVER_QUERY` to them (§5.2). They reach the socket and are denied:
  `resolv query` prints `resolv: access denied`. Deleting the value
  restores the compiled default without a restart (§2.3).

The stub listener is not governed by either, which is why direct DNS
clients are unaffected.

---

# 9.4 Stalls and Loops

_Peios / Peios technical reference / resolvd / Failure Modes_

> The failures that come from resolvd's single thread and its channel to netd — a stalled reply reader, a refused subscription, a lost registry watch, and cache capacity pressure.

## 9.4.1 A client that does not read

Native and stub TCP replies are written without blocking (§2.5). A client
that does not read can retain its own queued reply until the fixed
one-second write deadline, then its connection closes. It does not stop
other lookups. Each door holds at most 256 queued replies; if full, a
new reply that cannot be written immediately is closed too. Stub TCP
write failures are silent; native failures log `control: reply failed`.

## 9.4.2 netd refuses the subscription

When netd accepts the connection and answers `subscribe` with an error
— resolvd's account lacks `NETWORK_QUERY`, for instance — resolvd drops
the channel and retries with exponential backoff (§3.2): 0.5 seconds,
then 1, 2, 4, 8, and ten seconds between attempts until a good snapshot
resets the sequence. The log shows `subscribed to netd`, then `netd
refused the subscription`, then `lost the netd channel; reconnecting`
for each refusal. `resolv status` shows `netd`
as `not connected` except in the moment between each reconnection and
its refusal, and the scopes are whatever the last accepted snapshot
held, or none.

## 9.4.3 The registry watch is lost

When the watch cannot be armed at startup, or fails and cannot be
re-armed, configuration changes are no longer seen (§2.3). `resolv
status` still shows the fallback servers in use, so a change to
`FallbackServers` that does not appear there is the sign. A restart
reads the key again and re-arms the watch.

## 9.4.4 Cache capacity pressure

At 8 192 entries, storing a new key removes expired entries first. If
that creates room, it keeps every live entry; otherwise it replaces only
the entry with the earliest expiry (§4.5). A long answer lifetime does
not cause a sweep of shorter-lived answers that are still valid.

`cache_entries` can fall sharply when admission removes many expired
entries. Expiry alone does not remove them, so reaching 8 192 does not
mean 8 192 live answers. Looking up an expired key also removes it,
before its replacement arrives.

Older builds compared the sweep against the incoming answer's expiry
(§4.5). On those builds a day-long answer could empty a full cache of
live answers, causing a burst of upstream requests.

---

# Appendix A Constants and Limits

_Peios / Peios technical reference / resolvd_

> Every fixed value in resolvd, the NSS shim and resolv, with the article that describes it, and the paths and addresses they use.

The articles cited are authoritative; this appendix collects their
values in one place.

## A.1 Limits and timers

| Bound | Value | Described in |
|---|---|---|
| Native request size ceiling (payload) | 65 536 bytes | §5.1 |
| Maximum buffered native frame | 65 540 bytes | §5.1 |
| Native connection read chunk | 4 096 bytes | §5.1 |
| Native request delivery bound | 5 s | §5.1 |
| Native connections still sending a request | 256 | §5.1 |
| Queued native replies | 256 | §2.5 |
| Queued stub TCP replies | 256 | §2.5 |
| Native reply write deadline | 1 s total | §5.1 |
| Stub TCP connections still sending a query | 256 | §6.1 |
| Stub TCP query delivery bound | 10 s | §6.1 |
| Stub TCP message buffer | 65 537 bytes | §6.1 |
| Stub TCP read chunk | 4 096 bytes | §6.1 |
| Stub UDP read buffer | 4 096 bytes | §6.1 |
| Stub TCP reply write deadline | 1 s total | §6.2 |
| Classic UDP reply limit, no `OPT` | 512 bytes | §6.2 |
| EDNS0 payload size advertised, upstream and stub | 1 232 bytes | §4.7, §6.2 |
| Transaction timeout | 2 s | §4.6 |
| Attempts per question (all candidates and transports) | 3 | §4.6 |
| Demotion period | 30 s | §4.6 |
| Service descriptor limit | 16 384 | §2.1 |
| Listener retry delay after accept error | 250 ms | §2.5, §9.1 |
| Upstream transactions in flight | 4 096 | §4.6 |
| Upstream UDP read buffer | 4 096 bytes | §4.7 |
| Cache entries | 8 192 | §4.5 |
| Positive lifetime cap | 86 400 s | §4.5 |
| Negative lifetime cap | 300 s | §4.5 |
| CNAME chase depth in `lookup` | 16 | §4.9 |
| netd reconnect backoff | 0.5 s doubling to 10 s | §3.2 |
| netd channel write timeout | 2 s | §3.1 |
| netd frame ceiling | 65 536 bytes | §3.1 |
| netd channel read chunk | 8 192 bytes | §3.1 |
| Registry watch event buffer | 16 384 bytes | §2.3 |
| Kernel hostname read buffer | 256 bytes | §3.3 |
| Unknown-value nesting depth, native requests | 32 | §5.1 |
| NSS shim read and write timeout | 10 s | §7.1 |
| DNS label | 63 bytes | §4.1 |
| DNS name on the wire | 255 bytes | §4.1 |

## A.2 Rights

| Right | Value | Described in |
|---|---|---|
| `RESOLVER_QUERY` | `0x00000001` | §5.2 |
| `RESOLVER_CONTROL` | `0x00000002` | §5.2 |
| `RESOLVER_ALL_ACCESS` | `0x000F0003` | §5.2 |

## A.3 Paths, addresses and identities

| Item | Value | Described in |
|---|---|---|
| Daemon | `/usr/sbin/resolvd` | §2.1 |
| Operator command | `/usr/bin/resolv` | §8.1 |
| NSS shim | `/usr/lib/x86_64-linux-peios/libnss_peios_net.so.2` | §7.1 |
| Runtime directory | `/run/resolvd`, mode `0755` | §2.4 |
| Native socket | `/run/resolvd/resolv.sock`, mode `0666` | §2.4 |
| Stub listener | `127.0.0.53:53`, UDP and TCP | §2.4 |
| netd control socket | `/run/netd/control.sock` | §3.1 |
| Upstream port | 53 | §4.7 |
| Constant resolver configuration | `/usr/etc/resolv.conf` | §2.1 |
| Service definition seed | `/usr/share/regim/resolvd-service.reg` | §2.1 |
| Port reservation seed | `/usr/share/regim/resolvd-port.reg` | §2.1 |
| Registry reference | `/usr/share/regman/resolvd.regman` | §2.1 |
| Service SID | `S-1-5-80-3864064249-1823296737-2008945602-1354971773-2894779966` | §2.1 |
| Fallback scope key | `fallback` | §4.4 |

---

# Appendix B Registry Keys

_Peios / Peios technical reference / resolvd_

> Every registry key and value resolvd reads or its package seeds, with types, defaults and the article that describes each.

The articles cited are authoritative; this appendix collects the keys in
one place.

## B.1 Read by resolvd

| Key | Value | Type | Default | Described in |
|---|---|---|---|---|
| `Machine\System\Network\Dns` | `FallbackServers` | `REG_MULTI_SZ` (or one `REG_SZ` or `REG_EXPAND_SZ`) | none | §2.3 |
| `Machine\System\Network\Dns` | `ExtraSearchDomains` | `REG_MULTI_SZ` (or one `REG_SZ` or `REG_EXPAND_SZ`) | none | §2.3 |
| `Machine\System\Network\Dns` | `ControlSecurity` | `REG_BINARY` | the compiled default of §5.2 | §2.3, §5.2 |
| `Machine\System\Network\Dns\Hosts` | any name: a static name | `REG_SZ`, `REG_EXPAND_SZ` or `REG_MULTI_SZ` | none | §2.3, §4.2 |

resolvd watches `Machine\System\Network` and everything under it, and
re-reads `Machine\System\Network\Dns` whole on any change (§2.3). It
reads no other key.

## B.2 Seeded by the package

Both seeds are installed under `/usr/share/regim/` and are inert until
an image applies them (§2.1).

| Seed | Key | Values |
|---|---|---|
| `resolvd-service.reg` | `Machine\System\Services\resolvd` | `ImagePath`, `Triggers`, `Identity`, `Readiness`, `RuntimeDirectories`, `RestartPolicy`, `ErrorControl`, `DisplayName`, `Description` |
| `resolvd-port.reg` | `Machine\System\Network\TcpIp\PortReservations` | `tcp,udp:53` |
