#16485·nix

outputChecks: symbolic sibling-output references fail to resolve when the sibling was already valid

Author: hartmanfrostCreated Sep 18, 2026Updated Sep 18, 2026

Summary

outputChecks.<output>.{allowed,disallowed}{References,Requisites} entries that refer to a sibling output by symbolic name (e.g. "out") fail to resolve -- with a spurious ... but this is not a valid output of this derivation error -- whenever that sibling output happened to already be valid (e.g. GC-rooted by something unrelated) before the current build, and was therefore not rebuilt/re-registered by this invocation.

This is a false positive: the derivation output check machinery rejects a perfectly fine build because of how the other, unrelated output was registered, not because of anything wrong with the output actually being checked.

Real-world impact

Seen in the wild independently, on two different multi-output packages, both times because a sibling output ("out") had survived from an earlier, unrelated closure while the output actually being built had been garbage-collected:

  • nodejs-slim-24.19.0, output libv8 (whose disallowedReferences = ["out","npm","corepack"]):

    error: derivation '...-nodejs-slim-24.19.0.drv' output check for 'libv8'
    contains output name 'out', but this is not a valid output of this
    derivation. (Valid outputs are [corepack, dev, libv8, npm].)

    Note out is missing from the "Valid outputs" list even though it is a real, declared, and in this case perfectly valid output of the derivation -- it's just not in the restricted internal map the error message is built from (see Mechanism below).

The first case is an innocuous, correctly-built package that fails to build purely because of this bug.

A second report carries the same error with the roles mirrored -- this time it is out's own check that cannot resolve a sibling:

output check for 'out' contains output name 'corepack'

(nixpkgs#530015, comment by phanirithvij, also on nodejs-slim.) That message can only come from the checkRefs branch described below, so it is at minimum the same code path; I have not reproduced that particular failure, so I can't confirm the trigger there was asymmetric output validity rather than some other reason for the name to be missing. It is offered as a likely second sighting, not as independent confirmation.

Minimal reproduction

nix
# repro.nix
let pkgs = import <nixpkgs> { };
in pkgs.stdenv.mkDerivation {
  name = "oc-bug-repro";
  outputs = [ "out" "aux" ];
  __structuredAttrs = true;
  outputChecks.aux.disallowedReferences = [ "out" ];
  buildCommand = ''
    mkdir -p "$out" "$aux"
    echo hi > "$out/x"
    echo hi > "$aux/y"
  '';
}
bash
$ nix-build repro.nix -A out   # or: nix build .#... , build both outputs
$ nix-build repro.nix -A aux
# both succeed the first time -- nothing is asymmetric yet

$ rm result result-aux
$ nix store delete <the aux store path>     # invalidate ONLY aux; out stays valid
$ nix-build repro.nix -A aux
error: derivation '...-oc-bug-repro.drv' output check for 'aux' contains
output name 'out', but this is not a valid output of this derivation.
(Valid outputs are [aux].)

The rebuild fails even though aux does not, in fact, reference out at all -- the error fires purely while resolving the symbolic name "out" in disallowedReferences, before any actual reference is even examined. keep-outputs = true, which many long-running systems set, makes this much more likely to trigger in practice (an output that would normally be GC'd alongside its siblings instead lingers, valid, indefinitely, while its siblings churn).

Mechanism

In registerOutputs() (src/libstore/build/derivation-builder-impl.cc on master; src/libstore/unix/build/derivation-builder.cc on the 2.34 branch), each output computes:

cpp
bool wanted = buildMode == bmCheck || !(initialInfo.known && initialInfo.known->isValid());
if (!wanted) {
    outputReferencesIfUnregistered.insert_or_assign(
        outputName, AlreadyRegistered{.path = initialInfo.known->path});
    continue;
}

An output that was already valid before this build takes the AlreadyRegistered branch. Later, in the per-output registration loop, that branch's visitor does:

cpp
[&](const AlreadyRegistered & skippedFinalPath) -> std::optional<StorePathSet> {
    finish(skippedFinalPath.path);
    return std::nullopt;
},
cpp
if (!referencesOpt)
    continue;
...
infos.emplace(outputName, std::move(newInfo));

continue skips the rest of the loop body, so this output is never inserted into the local infos map -- even though the very next statement's comment says the opposite is intended:

cpp
/* Do this in both the check and non-check cases, because we
   want `checkOutputs` below to work, which needs these path
   infos. */
infos.emplace(outputName, std::move(newInfo));

infos is then passed to checkOutputs() (src/libstore/build/derivation-check.cc), whose checkRefs lambda resolves symbolic sibling-output references in outputChecks via:

cpp
[&](const OutputName & refOutputName) {
    if (auto output = get(outputs, refOutputName))
        spec.insert(output->path);
    else {
        ... throw BuildError(..., "not a valid output of this derivation", ...);
    }
}

outputs here is the restricted infos map from registerOutputs(). An already-valid sibling's name is therefore unresolvable, and the lookup falls into the error branch -- even though the derivation genuinely does declare that output, and it genuinely is valid in the store right now.

Note that this is purely a symbolic name→path resolution problem: getClosure() in the same file already falls back to store.queryPathInfo(path) for paths not found in the local outputsByPath map, so maxClosureSize/disallowedRequisites checks that reach an already-valid sibling via an actual store-path reference (rather than by symbolic name) are unaffected. Only the symbolic-name lookup lacks an equivalent fallback.

Relation to #14130 / #14137

NixOS/nix#14137 (merged 2025-10-31, fixing #14130) fixed this same AlreadyRegistered-skip mechanism, but at a different call site: in derivation-goal.cc, where the caller of registerOutputs() consumes its returned builtOutputs (which has the identical gap -- an already-valid output is missing from builtOutputs too, for the same reason). That fix replaced an assertion with a defensive fallback that reconstructs the missing entry from the store when needed:

cpp
/* If the wanted output is not in builtOutputs (e.g., because it
   was already valid and therefore not re-registered), we need to
   add it ourselves to ensure we return the correct information. */
if (success.builtOutputs.count(wantedOutput) == 0) {
    debug("BUG! wanted output '%s' not in builtOutputs, working around by adding it manually", wantedOutput);
    ...
}

That fix did not touch checkOutputs() / derivation-check.cc, so the symbolic-reference-resolution path described above is still broken on current master (verified against NixOS/nix@master as of this report).