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
validas auint64(0 or 1) on the wireRead a
uint64back from the wire into aboolRender a human-readable
__init__signature for IDE autocompletionSerialize 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 |
|---|---|
|
Iterates fields in declaration order, writing each to the wire |
|
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 bodyAuto-registration in
WIRE_REGISTRY(op code → request class)ClassVars for
response_typeand the operation code. The base model also carries neutral capability metadata used by consumers; daemon dispatch policy belongs to consumers such aspynixd, not the reusable codec package.
WireResponse adds:
A
logs: WireLogsfield — 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 |
|---|---|
|
|
|
|
|
length-prefixed UTF-8 |
|
|
|
length-prefixed bytes |
|
|
|
|
|
|
|
same as |
|
single length-prefixed string |
|
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 |
|---|---|---|
|
JSON-encoded on wire |
length-prefixed UTF-8 containing JSON |
|
Single |
raw |
|
Tagged-union stream |
|
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: