Daemon Protocol Implementation

The reusable nix_daemon_protocol package implements the Nix daemon wire protocol using a Pydantic-based serialization framework. Every protocol message (request and response) is a Pydantic model that auto-generates its own binary encoder and decoder from Python type annotations. pynixd consumes this package for daemon transport and execution.

Supported protocol versions

The codec package supports the contiguous 1.32 through 1.38 interval. Fast in-memory compatibility tests run every protocol boundary in that interval. Expensive real-daemon tests remain anchored at 1.32 (nixbuild.net) and the current Nix protocol. 1.35 (Lix) is supported, and no real-daemon test runs it. Requests also declare their introduction version and reject attempts to send an operation to an older negotiated daemon.

Deserialization diagnostics

The protocol package reports malformed wire data through an optional logging adapter. It uses structlog when the embedding environment has it installed and falls back to the standard-library nix_daemon_protocol logger otherwise. The package never configures either logging system. Consumers can also provide a logger in ReadContext.

Recursive decode failures produce one daemon_deserialization_failed event at the outermost message boundary, then re-raise the original exception. Events contain message type, protocol version, operation (when known), reader ID, and exception type; they never include raw wire payload data.

How it works

You write a normal Pydantic model:

class IsValidPathResponse(WireResponse):
    """IsValidPath response — valid bool as uint64 on wire."""

    valid: bool

And it automatically knows how to:

  • Write valid as a uint64 (0 or 1) on the wire

  • Read a uint64 back from the wire into a bool

  • Render a human-readable __init__ signature for IDE autocompletion

  • Serialize to/from JSON for debugging

No manual read_uint64 / write_uint64 calls. Types are inferred from annotations.

Base classes

WireModel — the engine

WireModel is an abstract base class inheriting from Pydantic's BaseModel. It introspects the model's type annotations and synthesizes two methods:

Method

Purpose

to_writer(ctx)

Iterates fields in declaration order, writing each to the wire

from_reader(ctx)

Classmethod — reads fields from the wire in declaration order

Class variables (ClassVar[T]) are skipped by default — they hold protocol constants (like op codes) that are handled by the transport layer, not the message body.

Nested WireModel fields are serialized inline — the child's fields are written directly into the parent's wire stream with no length prefix. This matches the Nix daemon's flat wire format.

WireString — single string on the wire

Some types are represented as a single length-prefixed UTF-8 string on the wire — StorePath ("/nix/store/hash-name"), Signature ("name:base64sig"), DerivedPath ("drv!out"), etc. These inherit from WireString, which overrides to_writer/from_reader to read/write the whole object as one string.

Multi-field WireString subclasses (like Signature and DrvOutput) override from_str/to_str to parse/format with delimiters.

WireRequest / WireResponse — operation envelopes

WireRequest adds:

  • An op code (uint64) written before the message body

  • Auto-registration in WIRE_REGISTRY (op code → request class)

  • ClassVars for response_type and the operation code. The base model also carries neutral capability metadata used by consumers; daemon dispatch policy belongs to consumers such as pynixd, not the reusable codec package.

WireResponse adds:

  • A logs: WireLogs field — the daemon's stderr stream, written/read before body fields

Here's a complete operation pair:

class BuildDerivationRequest(WireRequest):
    op: ClassVar[int] = 36
    response_type = BuildDerivationResponse
    forward: ClassVar[bool] = False  # never forwarded — handled by pynixd

    drv_path: StorePath
    derivation: BasicDerivation
    build_mode: int


class BuildDerivationResponse(WireResponse):
    result: BuildResult

Wire type dispatch

The serialization engine maps Python types to wire primitives:

Python type

Wire format

int

uint64

IntEnum

uint64 (value)

str

length-prefixed UTF-8

bool

uint64 (0 or 1)

bytes

length-prefixed bytes

list[T]

uint64 count, then N elements

set[T]

uint64 count, then N elements

dict[K, V]

uint64 count, then N key-value pairs

Optional[T]

same as T (absent/none is context-dependent)

WireString subclass

single length-prefixed string

WireModel subclass

inline field-by-field

Special case: Optional[StorePath] writes "" for None — this is Nix's convention for an absent deriver field.

Version constraints

The Nix daemon protocol evolved across versions. Some fields only exist on the wire at certain protocol versions. WireField supports this via min_version and max_version:

times_built: int | None = WireField(default=None, min_version=proto(1, 29))
cpu_user: OptMicroseconds = WireField(default_factory=OptMicroseconds, min_version=proto(1, 37))

Fields outside the negotiated version range are skipped during both serialization and deserialization.

Conditional fields

Some fields depend on the value of another field at runtime — they only appear on the wire under certain conditions. WireField supports this via wire_depends_on, a predicate that receives self:

info: UnkeyedValidPathInfo | None = WireField(
    default=None,
    wire_depends_on=lambda self: self.valid,
)

During deserialization, valid is read first. If it's False, info is skipped — the field stays at its default None.

Custom serialization

Some types override from_reader/to_writer entirely, bypassing field-by-field serialization:

Type

Reason

Wire format

Realisation

JSON-encoded on wire

length-prefixed UTF-8 containing JSON

Time / TimeSpan

Single uint64 — faster

raw uint64

WireLogs

Tagged-union stream

[uint64 code][body]...[STDERR_LAST]

Stderr stream

Every response carries a stderr stream (WireLogs) — a sequence of tagged-union messages:

[uint64 STDERR_NEXT][length-prefixed string]
[uint64 STDERR_START_ACTIVITY][activity fields...]
[uint64 STDERR_STOP_ACTIVITY][activity id]
[uint64 STDERR_RESULT][result fields...]
[uint64 STDERR_ERROR][error fields...]    ← terminates the stream, no STDERR_LAST after
[uint64 STDERR_LAST]                       ← normal end of stream

WireLogs.from_reader reads messages until it hits STDERR_LAST or a LogError. WireLogs.to_writer writes each message followed by STDERR_LAST.

Type reference

The following pages catalogue every type, generated from source code: