outputChecks: symbolic sibling-output references fail to resolve when the sibling was already valid
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, outputlibv8(whosedisallowedReferences = ["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
outis 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
# 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"
'';
}$ 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:
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:
[&](const AlreadyRegistered & skippedFinalPath) -> std::optional<StorePathSet> {
finish(skippedFinalPath.path);
return std::nullopt;
},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:
/* 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:
[&](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:
/* 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).
Source: NixOS/nix