Source code for nix_daemon_protocol.build_result

"""BuildResult and related types — build outcome on the Nix daemon wire."""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from enum import IntEnum

from .constants import FEATURE_REALISATION_WITH_PATH, proto
from .drv_output import DrvOutput
from .opt_microseconds import OptMicroseconds
from .realisation import Realisation
from .store_dir import store_prefix
from .store_path import StorePath
from .unkeyed_realisation import UnkeyedRealisation
from .wire_message import WireField, WireModel

# The highest status byte a Nix client can decode. `buildResultStatusTable` in
# `src/libstore/common-protocol.cc` holds 15 entries, and the reader rejects any
# index at or above that size. `BuildResult.wire_status` is what keeps this
# project inside it.
MAX_WIRE_STATUS = 14


DUMMY_DRV_HASH = "sha256:" + "0" * 64
"""`Hash::dummy` in base 16, the hash Nix writes into an old-shape `DrvOutput` key."""


[docs] class BuildResultStatus(IntEnum): """Build result status codes from nix daemon protocol.""" BUILT = 0 SUBSTITUTED = 1 ALREADY_VALID = 2 RESOLVES_TO_ALREADY_VALID = 13 PERMANENT_FAILURE = 3 INPUT_REJECTED = 4 OUTPUT_REJECTED = 5 TRANSIENT_FAILURE = 6 CACHED_FAILURE = 7 TIMED_OUT = 8 MISC_FAILURE = 9 DEPENDENCY_FAILED = 10 LOG_LIMIT_EXCEEDED = 11 NOT_DETERMINISTIC = 12 NO_SUBSTITUTERS = 14 HASH_MISMATCH = 101 UNKNOWN = 102 @property def is_success(self) -> bool: return self in { self.BUILT, self.SUBSTITUTED, self.ALREADY_VALID, self.RESOLVES_TO_ALREADY_VALID, } @property def is_failure(self) -> bool: return not self.is_success
[docs] class BuildMode(IntEnum): """Build mode flags from nix daemon protocol.""" NORMAL = 0 REPAIR = 1 CHECK = 2
[docs] @dataclass class BuiltOutput: """A built output - either plain path or content-addressed (CA) format.""" out_path: StorePath = field(default_factory=lambda: StorePath(path="")) ca: str = "" hash: str = "" hash_algo: str = "" nar_hash: str = "" nar_size: int = 0 reference: str = "" @classmethod def from_string(cls, s: str) -> BuiltOutput: if not s: return cls() try: data = json.loads(s) if isinstance(data, dict): return cls( out_path=StorePath(path=data.get("outPath", "")), ca=data.get("ca", ""), hash=data.get("hash", ""), hash_algo=data.get("hashAlgo", ""), nar_hash=data.get("narHash", ""), nar_size=data.get("narSize", 0), reference=data.get("reference", ""), ) except (json.JSONDecodeError, TypeError): pass return cls(out_path=StorePath(path=s)) def to_string(self) -> str: if self.ca or self.hash or self.nar_hash: data: dict[str, str | int] = {"outPath": str(self.out_path)} if self.ca: data["ca"] = self.ca if self.hash: data["hash"] = self.hash if self.hash_algo: data["hashAlgo"] = self.hash_algo if self.nar_hash: data["narHash"] = self.nar_hash if self.nar_size: data["narSize"] = self.nar_size if self.reference: data["reference"] = self.reference return json.dumps(data) return str(self.out_path)
def _whole_path(path: object) -> StorePath: """A store path with the store directory in front of it. **The two shapes of a realisation spell a path differently.** The JSON of `Realisation` carries `<hash>-<name>`, which is `StorePath::to_string` of Nix, and `UnkeyedRealisation` carries a `StorePath` on the wire, which is the whole path. A bare name where the wire wants a whole path makes the peer answer "not an absolute path: '...'", and `ca:build-cache` read that. Issue #14. """ text = str(path) if not text or text.startswith("/"): return StorePath(text) return StorePath(store_prefix() + text) def _bare_path(path: object) -> StorePath: """A store path with the store directory taken off. See `_whole_path`.""" text = str(path) prefix = store_prefix() return StorePath(text.removeprefix(prefix))
[docs] class BuildResult(WireModel): """Nix daemon protocol BuildResult. Fields present based on protocol version: - All versions: status, error_msg - >= 1.29: times_built, is_non_deterministic, start_time, stop_time - >= 1.37: cpu_user, cpu_system - >= 1.28: built_outputs (dict[str, str]) """ status: int = 0 error_msg: str = WireField(default="", text_errors="surrogateescape") """What the daemon said about the failure, byte for byte. `BuildResult.errorMsg` is a `std::string` that the daemon forwards without validating, and pynixd quotes `LogError.msg` into it on the `queue.fail` path -- both already carry bytes no UTF-8 decoder accepts. Strict decoding would move the crash of issue nixidae/nixidae#662 from the log stream to the result that follows it. """ # Protocol 1.29 fields times_built: int | None = WireField(default=None, min_version=proto(1, 29)) is_non_deterministic: int | None = WireField(default=None, min_version=proto(1, 29)) start_time: int | None = WireField(default=None, min_version=proto(1, 29)) stop_time: int | None = WireField(default=None, min_version=proto(1, 29)) # Protocol 1.37 fields cpu_user: OptMicroseconds = WireField(default_factory=OptMicroseconds, min_version=proto(1, 37)) cpu_system: OptMicroseconds = WireField(default_factory=OptMicroseconds, min_version=proto(1, 37)) # Protocol 1.28 fields # # **`builtOutputs` of Nix is two fields, and a feature picks which one.** # `worker-protocol.cc:268` writes it as an if/else. With # `realisation-with-path-not-hash` it is a map of output name to # `UnkeyedRealisation`; without it, and from 1.28, it is a map of # `"<drvHash>!<output>"` to a JSON `Realisation`. Both live at 1.38, so a # version alone cannot separate them. Issue #14. built_outputs: dict[str, Realisation] | None = WireField( default=None, min_version=proto(1, 28), unless_features=[FEATURE_REALISATION_WITH_PATH], ) built_outputs_by_name: dict[str, UnkeyedRealisation] | None = WireField( default=None, needs_features=[FEATURE_REALISATION_WITH_PATH], ) """The map that the feature shape carries, keyed by the output name alone. The key of `built_outputs` is a whole `DrvOutput`; this one names the output of the derivation that the answer is already about, so it needs no derivation in the key. **The two spell a path differently.** This one carries a whole store path, because the wire writes it as a `StorePath`. The JSON of `Realisation` carries the bare `<hash>-<name>`. `_whole_path` and `_bare_path` are the translation, and `ca:build-cache` measured what skipping it costs. """
[docs] def realised_outputs(self) -> dict[str, Realisation]: """Every output this result realised, keyed by the **output name**. One accessor for the two wire shapes, so a caller needs no branch. `built_outputs` keys by a whole `DrvOutput`, which is `"<drvHash>!<output>"`, and `built_outputs_by_name` keys by the output name alone. This takes the part after the `!` in the first case. **A realisation of the feature shape carries no id.** The request already named the derivation, so `worker-protocol.cc:268` writes the output name as the key and an `UnkeyedRealisation` as the value. The `Realisation` this builds therefore has an empty `id`, and a caller that needs the id must build it from the derivation it already holds. No caller in this repository does: each one reads `out_path` and the key. Issue #14. """ if self.built_outputs_by_name: return { name: Realisation(out_path=_bare_path(value.out_path), signatures=sorted(value.signatures)) for name, value in self.built_outputs_by_name.items() } if self.built_outputs: return {key.rpartition("!")[2] or key: value for key, value in self.built_outputs.items()} return {}
[docs] def wire_status(self) -> int: """The status byte to send, which is not always the one held. A hash mismatch is a kind of output rejection, which is the mapping Nix chose and the one a client expects. Everything else out of range becomes a plain failure: the detail belongs in `error_msg`, which has no such limit, and a status a client cannot read carries no detail at all. """ if self.status == BuildResultStatus.HASH_MISMATCH: return int(BuildResultStatus.OUTPUT_REJECTED) if 0 <= self.status <= MAX_WIRE_STATUS: return self.status return int(BuildResultStatus.MISC_FAILURE)
[docs] def for_the_wire(self, features: frozenset[str] | None = None) -> BuildResult: """This result, with a status a client can decode and a map it can read. Call this before sending a result to a client. It is a method and not a `to_writer` override on purpose: `experimental_compiled` refuses to compile a model that overrides its codec (`_can_compile`), and `BuildResult` is the model its own tests compile as the example. A correctness fix that quietly turned off the fast path for the hottest model on the wire would be a poor trade, so the knowledge of what is wire-safe lives here and the caller decides when to apply it. **A proxy reads a result on one connection and writes it on another, and the two negotiate their features apart.** A backend that offers `realisation-with-path-not-hash` fills `built_outputs_by_name` and leaves `built_outputs` at `None`; a client that offers nothing then reads `built_outputs`, and a `None` where a map belongs raises in the writer. Give *features* the set of the peer this result is going to, and this fills the field that peer will read. **The fill from the feature shape to the old one carries a dummy hash.** The old key is a whole `DrvOutput`, and the feature shape carries no hash. Nix writes `sha256:<64 zeros>!<output>` there, because an old client reads only the output name and the path (`worker-protocol.cc:313`). A key without the `!` makes Nix 2.35's reader throw "Invalid derivation output id" (`worker-protocol.cc:272`). Issue #14, nixidae/nixidae#1003. """ update: dict[str, object] = {} wire_status = self.wire_status() if wire_status != self.status: update["status"] = wire_status if features is not None: wants_the_feature = FEATURE_REALISATION_WITH_PATH in features if wants_the_feature and self.built_outputs_by_name is None: update["built_outputs_by_name"] = { name: UnkeyedRealisation(out_path=_whole_path(value.out_path), signatures=set(value.signatures)) for name, value in self.realised_outputs().items() if value.out_path is not None } elif not wants_the_feature and self.built_outputs is None: update["built_outputs"] = { str(drv_output): value.model_copy(update={"id": drv_output}) for name, value in self.realised_outputs().items() for drv_output in [DrvOutput(drv_hash=DUMMY_DRV_HASH, output_name=name)] } if not update: return self return self.model_copy(update=update)