One of my very first open questions, all the way back in the quick start guide, was this:

How do I collect garbage without making my next invocation of nix-shell -p hello redownload all the stuff it needs to be a shell?

Since the time I wrote that question, a lot has changed. I read the entire Nix manual. I read the entire Nixpkgs manual. I’ve been using Nix for months.

And I still have no idea what the answer is.

Now, I should caveat this a little: I don’t really need to know the answer to this particular question. nix-shell -p is incredibly useful, but I don’t care that much about saying “add whatever is going to be in the standard environment to my GC roots.” I mean, that’s certainly a reasonable thing to say. But I would settle for this more useful question:

How do I add a shell.nix file to my GC roots?

And I’ll sort of wave my hands and assume that that would get me all the same packages as nix-shell -p – which I think is true, as long as my shell.nix used the standard builder. I could then just add a simple empty shell.nix to my GC roots, and it would give me the same thing.

But I don’t know how to do that.

I mean, I remember how to add GC roots: ln -s. But of course that doesn’t work if you just add a symlink to a shell.nix file. Adding a symlink to a shell.nix file does nothing, as a matter of fact.

Which is upsetting, because I want to be able to add a shell.nix file as a GC root. I want that to mean “Okay, now when I collect garbage, don’t delete all the things that you need when I run nix-shell.”

But it doesn’t mean that.

And I get why. GC roots are, after all, for store objects. You can’t just add arbitrary .nix files. .nix files aren’t in the store. Although that’s certainly the “API” I expect – the API I want, as that feels very intuitive to me – I understand that it does not actually mesh with my understanding (and the manual’s explanation) of garbage collection. And, you know, it would mean that garbage collection would need to evaluate arbitrary Nix expressions, and you’d have to worry about non-termination during that evaluation, and I understand why it’s not an option.

So, okay. In order to add a “shell file” as a root, we have to do something else. What, exactly?

I have no idea.

Or rather, I had no idea. Spoilers: I already know how to do this, sort of, because I did it, once, in sort of a panic.

I was dangerously close to running out of disk space while working through one of the Nixpkgs chapters, and I needed to collect garbage, but I really didn’t want to have to rebuild GCC from source again. So I googled, and found a hit on the wiki, which said:1

When you invoke nix-shell with

$ nix-instantiate shell.nix --indirect --add-root $DIR/.nix-gc-roots/shell.drv ...

then you’ll have a persistent environment which won’t be garbage collected. It is useful when you don’t want to spend time waiting for redownloads every time you enter the shell.

A little problem exists though. GC roots are numbered sequentially, so if you change shell.nix to contain less derivations, and name of last GC root will start with shell.drv-7, then shell.drv-{8,9,10,11,12}* will be dangling and unused. To overcome this problem you should remove GC roots dir periodically (or just before nix-shell)

And I will be the first to say: I don’t understand a word of that.

I mean, I get that it’s telling me to run some command. But I don’t understand the command: how does that invoke nix-shell? Grepping my blog tells me that I have run nix-instantiate before, technically, but I couldn’t tell you what it does or why I ran it. I really don’t understand the note about the numbering problem, either.

But I was in a hurry, so I ran the command, even though I didn’t understand it. And guess what? It didn’t work.

And I found out why it didn’t work, eventually, in some comment on some GitHub issue that I didn’t even write down because I was in sort of a rush, and I’ll get to the missing piece later. But first I’d like to make this a little more concrete, so I can replay the experience without the same time or space pressure, and see if I can make this make sense to me.

So: in order to add a GC root, we need to add a store object.

That part is simple enough.

So we need to create a store object that represents the “result” of evaluating shell.nix?

But shell.nix is different from, like, default.nix. I understand default.nix. I understand nix-build. I understand running nix-shell with a default.nix and being dropped into a shell containing the dependencies of that default.nix.

I guess that I don’t really understand shell.nix files, then.

Like, they’re sort of… half of that. Right? They’re just a set of dependencies? But they don’t, themselves, represent any derivation. I can’t “build” a shell.nix. What would it build?

Let’s find out.

I have a trivial shell.nix file sitting around. If I try to “build” it:

$ nix-build shell.nix
these derivations will be built:
  /nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv
building '/nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv'...
nobuildPhase

This derivation is not meant to be built, aborting

builder for '/nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv' failed with exit code 1
error: build of '/nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv' failed

I was expecting that, because I remember this, from the documentation of mkShell:

pkgs.mkShell is a special kind of derivation that is only useful when using it combined with nix-shell. It will in fact fail to instantiate when invoked with nix-build.

So I expected that to fail, although I never really thought about how when I originally read that. Now that I think about it, it probably defines some trivial derivation, and that derivation checks lib.trivial.inNixShell, and throws an error or something?

Let’s see. I find the source in pkgs/build-support/mkshell/default.nix, and long and short of it is:

stdenv.mkDerivation ({
  name = "nix-shell";
  phases = ["nobuildPhase"];

  buildInputs = mergeInputs "buildInputs";
  nativeBuildInputs = mergeInputs "nativeBuildInputs";
  propagatedBuildInputs = mergeInputs "propagatedBuildInputs";
  propagatedNativeBuildInputs = mergeInputs "propagatedNativeBuildInputs";

  shellHook = lib.concatStringsSep "\n" (lib.catAttrs "shellHook"
    (lib.reverseList inputsFrom ++ [attrs]));

  nobuildPhase = ''
    echo
    echo "This derivation is not meant to be built, aborting";
    echo
    exit 1
  '';
} // rest)

Okay. So no; it doesn’t throw, and it doesn’t even check inNixShell – it fails during “realization” or whatever Nix calls that. Which makes sense, when I think about it: if it threw, then nix-shell probably wouldn’t be able to evaluate its dependencies. This is a nice simpler thing.

So, okay, it’s just a dummy derivation that fails whenever you build it.

So… the question of putting it in the nix-store becomes a tricky one. There is no “result” that we can point to. Except, well, I cheated, so I know that there is: whatever that nix-instantiate command gives us.

So let’s figure out what that does. man nix-instantiate says:

nix-instantiate - instantiate store derivations from Nix expressions

Basically, this creates .drv files.

$ nix-instantiate shell.nix
warning: you did not specify '--add-root'; the result might be removed by the garbage collector
/nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv

Okay.

That /nix/store/xxx-nix-shell.drv file is, of course, the same weird internal intermediate representation (??) of a derivation that we’ve seen before, but which has never been explained.

But! I think I know the format of it. It seems to be an “ATerm” file. At least, nix-instantiate makes mention of --json and --xml alternatives to the default ATerm format, and googling “aterm expressions” shows me that, yes, they look like this file. Seems to be short for “Annotated Term,” and “annotated term format” gets a few hits – including a few pretty printers.

But according to nix search, none of these pretty printers are available in Nixpkgs. Meanwhile…

$ brew info aterm
aterm: stable 2.8 (bottled)
Annotated Term for tree-like ADT exchange
https://strategoxt.org/Tools/ATermFormat
Not installed
From: https://github.com/Homebrew/homebrew-core/blob/HEAD/Formula/aterm.rb
==> Analytics
install: 6 (30 days), 16 (90 days), 72 (365 days)
install-on-request: 3 (30 days), 10 (90 days), 43 (365 days)
build-error: 0 (30 days)

But this does not actually contain the pp-aterm tool that I want. Sigh.

There is a reference to the strategoxt package in the release notes of Nixpkgs… but no such package exists anymore. Nor do I know if it would contain this pretty-printer. And it seems strategoxt has been replaced by… an Eclipse plugin? I have no idea, folks.

At this point it might be faster to just write my own pretty-printer for this stupid file format rather than trying to find one.

Sigh, no, I persist:

$ brew tap metaborg/metaborg
...
$ brew install strategoxt
==> Installing strategoxt from metaborg/metaborg

It’s doing stuff. It’s downloading a lot of things. It’s downloading… OpenJDK?? Abort. Abort. Nothing is worth this.

What did S-expressions ever do to you. Why is this not just an S-expression.

You know, it’s pretty close to one…

$ nix-env -iA nixpkgs.ocamlPackages.sexp
... compiling the entire ocaml ecosystem from source, because apparently none of it is cached ...

$ tr ',[]' ' ()' </nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv \
> | xargs -0 -n1 printf '(%s)' \
> | sexp pp
(Derive (
  ((out /nix/store/5i9c80x5h0mjpxwfnj6iz8ykivzjrcyq-nix-shell "" ""))
  ((/nix/store/8mgqiwshdp8pvl7agmi1ls6m2ziwp4ag-stdenv-darwin.drv (out))
   (/nix/store/gngpiflribp4h76hz4yrfm68vhs50d23-python3.9-cram-0.7.drv (out))
   (/nix/store/s5mjnf3b6nmxpyy640mx0s78zspihn2y-bash-4.4-p23.drv (out))
   (/nix/store/sqs3pqphn9jbizwxmp8zvfcfg1lp2prm-souffle-2.0.2.drv (out)))
  (/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh)
  x86_64-darwin
  /nix/store/30njb8l701pwnm5ya749fh2cgyc2d70m-bash-4.4-p23/bin/bash
  (-e /nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh)
  ((__darwinAllowLocalNetworking "")
   (__impureHostDeps
    "/bin/sh /usr/lib/libSystem.B.dylib /usr/lib/system/libunc.dylib /dev/zero /dev/random /dev/urandom /bin/sh")
   (__propagatedImpureHostDeps "")
   (__propagatedSandboxProfile "")
   (__sandboxProfile           "")
   (buildInputs                "")
   (builder /nix/store/30njb8l701pwnm5ya749fh2cgyc2d70m-bash-4.4-p23/bin/bash)
   (configureFlags             "")
   (depsBuildBuild             "")
   (depsBuildBuildPropagated   "")
   (depsBuildTarget            "")
   (depsBuildTargetPropagated  "")
   (depsHostHost               "")
   (depsHostHostPropagated     "")
   (depsTargetTarget           "")
   (depsTargetTargetPropagated "")
   (doCheck                    "")
   (doInstallCheck             "")
   (name                       nix-shell)
   (nativeBuildInputs
    "/nix/store/n0wm6l7qk9ygzjd76ashf9xx7f0z3kh6-souffle-2.0.2 /nix/store/axzlbh5ji9pr97mi04f70lizn22bkxj5-python3.9-cram-0.7")
   (nobuildPhase
    "echo\necho \"This derivation is not meant to be built  aborting\";\necho\nexit 1\n")
   (out /nix/store/5i9c80x5h0mjpxwfnj6iz8ykivzjrcyq-nix-shell)
   (outputs                     out)
   (patches                     "")
   (phases                      nobuildPhase)
   (propagatedBuildInputs       "")
   (propagatedNativeBuildInputs "")
   (shellHook                   "")
   (stdenv /nix/store/59hdixd6qf2jq5mj0bqiwrhy7621wa7j-stdenv-darwin)
   (strictDeps "")
   (system     x86_64-darwin))))

There. Screw your stupid format.

I looked at one of these before, long ago, that I manually attempted to pretty-print. I couldn’t make any sense of it. Can I make any sense of this one?

The first few “arguments” to the Derive “function” look like – and I am completely guessing here –

1. list of outputs...? (no idea about the empty strings)
  ((out /nix/store/5i9c80x5h0mjpxwfnj6iz8ykivzjrcyq-nix-shell "" ""))

2. map from build-time dependencies to the outputs that require them?
  ((/nix/store/8mgqiwshdp8pvl7agmi1ls6m2ziwp4ag-stdenv-darwin.drv (out))
   (/nix/store/gngpiflribp4h76hz4yrfm68vhs50d23-python3.9-cram-0.7.drv (out))
   (/nix/store/s5mjnf3b6nmxpyy640mx0s78zspihn2y-bash-4.4-p23.drv (out))
   (/nix/store/sqs3pqphn9jbizwxmp8zvfcfg1lp2prm-souffle-2.0.2.drv (out)))

3. maybe a list of non-derivation build-time dependencies?
  (/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh)

4. system
  x86_64-darwin

5. actual builder executable
  /nix/store/30njb8l701pwnm5ya749fh2cgyc2d70m-bash-4.4-p23/bin/bash

6. arguments to the builder
  (-e /nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh)

And then the next “argument” reminds me a lot of what you see when you finally coerce nix repl to print out a derivation:

nix-repl> shell = import ./shell.nix

nix-repl> shell
Ā«derivation /nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drvĀ»

nix-repl> shell // { type = "divination" }
error: syntax error, unexpected '}', expecting ';', at (string):1:32

nix-repl> :p shell // { type = "divination"; }
{ __darwinAllowLocalNetworking = false; __ignoreNulls = true; __impureHostDeps = [ "/bin/sh" "/usr/lib/libSystem.B.dylib" "/usr/lib/system/libunc.dylib" "/dev/zero" "/dev/random" "/dev/urandom" "/bin/sh" ]; __propagatedImpureHostDeps = [ ]; __propagatedSandboxProfile = [ "" ]; __sandboxProfile = ""; all = [ Ā«derivation /nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drvĀ» ]; args = [ "-e" /nix/store/4rvsrjbd2f351zgdh38as0xzwrlmvzkm-nixpkgs-21.05pre287374.1c16013bd6e/nixpkgs/pkgs/stdenv/generic/default-builder.sh ]; buildInputs = [ ]; builder = "/nix/store/30njb8l701pwnm5ya749fh2cgyc2d70m-bash-4.4-p23/bin/bash"; configureFlags = [ ]; depsBuildBuild = [ ]; depsBuildBuildPropagated = [ ]; depsBuildTarget = [ ]; depsBuildTargetPropagated = [ ]; depsHostHost = [ ]; depsHostHostPropagated = [ ]; depsTargetTarget = [ ]; depsTargetTargetPropagated = [ ]; doCheck = false; doInstallCheck = false; drvAttrs = { __darwinAllowLocalNetworking = false; __ignoreNulls = true; __impureHostDeps = Ā«repeatedĀ»; __propagatedImpureHostDeps = Ā«repeatedĀ»; __propagatedSandboxProfile = Ā«repeatedĀ»; __sandboxProfile = ""; args = Ā«repeatedĀ»; buildInputs = Ā«repeatedĀ»; builder = "/nix/store/30njb8l701pwnm5ya749fh2cgyc2d70m-bash-4.4-p23/bin/bash"; configureFlags = Ā«repeatedĀ»; depsBuildBuild = Ā«repeatedĀ»; depsBuildBuildPropagated = Ā«repeatedĀ»; depsBuildTarget = Ā«repeatedĀ»; depsBuildTargetPropagated = Ā«repeatedĀ»; depsHostHost = Ā«repeatedĀ»; depsHostHostPropagated = Ā«repeatedĀ»; depsTargetTarget = Ā«repeatedĀ»; depsTargetTargetPropagated = Ā«repeatedĀ»; doCheck = false; doInstallCheck = false; name = "nix-shell"; nativeBuildInputs = [ Ā«derivation /nix/store/sqs3pqphn9jbizwxmp8zvfcfg1lp2prm-souffle-2.0.2.drvĀ» Ā«derivation /nix/store/gngpiflribp4h76hz4yrfm68vhs50d23-python3.9-cram-0.7.drvĀ» ]; nobuildPhase = "echo\necho \"This derivation is not meant to be built, aborting\";\necho\nexit 1\n"; outputs = [ "out" ]; patches = [ ]; phases = [ "nobuildPhase" ]; propagatedBuildInputs = [ ]; propagatedNativeBuildInputs = [ ]; shellHook = ""; stdenv = Ā«derivation /nix/store/8mgqiwshdp8pvl7agmi1ls6m2ziwp4ag-stdenv-darwin.drvĀ»; strictDeps = false; system = "x86_64-darwin"; userHook = null; }; drvPath = "/nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv"; inputDerivation = Ā«derivation /nix/store/q8smqzbwa9w8aqw2872p1nrb7x98vs78-nix-shell.drvĀ»; meta = { available = true; broken = false; insecure = false; name = "nix-shell"; outputsToInstall = [ "out" ]; position = "/nix/store/4rvsrjbd2f351zgdh38as0xzwrlmvzkm-nixpkgs-21.05pre287374.1c16013bd6e/nixpkgs/pkgs/build-support/mkshell/default.nix:28"; unfree = false; unsupported = false; }; name = "nix-shell"; nativeBuildInputs = Ā«repeatedĀ»; nobuildPhase = "echo\necho \"This derivation is not meant to be built, aborting\";\necho\nexit 1\n"; out = Ā«derivation /nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drvĀ»; outPath = "/nix/store/5i9c80x5h0mjpxwfnj6iz8ykivzjrcyq-nix-shell"; outputName = "out"; outputUnspecified = true; outputs = Ā«repeatedĀ»; overrideAttrs = Ā«lambda @ /nix/store/4rvsrjbd2f351zgdh38as0xzwrlmvzkm-nixpkgs-21.05pre287374.1c16013bd6e/nixpkgs/pkgs/stdenv/generic/make-derivation.nix:374:28Ā»; passthru = { }; patches = Ā«repeatedĀ»; phases = Ā«repeatedĀ»; propagatedBuildInputs = Ā«repeatedĀ»; propagatedNativeBuildInputs = Ā«repeatedĀ»; shellHook = ""; stdenv = Ā«repeatedĀ»; strictDeps = false; system = "x86_64-darwin"; type = "divination"; userHook = null; }

Well, that’s kind of a mess, actually, because of the Ā«repeatedĀ» business, but it looks like a superset of the final argument to Derive in the .drv file, and I mostly-automatically-but-also-manually get it down to this S-expression, pulling out boring stuff like drvAttrs and meta:

((__darwinAllowLocalNetworking false)
 (__ignoreNulls                true)
 (__impureHostDeps (
   /bin/sh
   /usr/lib/libSystem.B.dylib
   /usr/lib/system/libunc.dylib
   /dev/zero
   /dev/random
   /dev/urandom
   /bin/sh))
 (__propagatedImpureHostDeps ())
 (__propagatedSandboxProfile (""))
 (__sandboxProfile "")
 (all ("Ā«derivation /nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drvĀ»"))
 (args (
   -e
   /nix/store/4rvsrjbd2f351zgdh38as0xzwrlmvzkm-nixpkgs-21.05pre287374.1c16013bd6e/nixpkgs/pkgs/stdenv/generic/default-builder.sh))
 (buildInputs ())
 (builder /nix/store/30njb8l701pwnm5ya749fh2cgyc2d70m-bash-4.4-p23/bin/bash)
 (configureFlags             ())
 (depsBuildBuild             ())
 (depsBuildBuildPropagated   ())
 (depsBuildTarget            ())
 (depsBuildTargetPropagated  ())
 (depsHostHost               ())
 (depsHostHostPropagated     ())
 (depsTargetTarget           ())
 (depsTargetTargetPropagated ())
 (doCheck        false)
 (doInstallCheck false)
 (drvPath /nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv)
 (inputDerivation
  "Ā«derivation /nix/store/q8smqzbwa9w8aqw2872p1nrb7x98vs78-nix-shell.drvĀ»")
 (name              nix-shell)
 (nativeBuildInputs Ā«repeatedĀ»)
 (nobuildPhase
  "echo\necho \"This derivation is not meant to be built, aborting\";\necho\nexit 1\n")
 (out
  "Ā«derivation /nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drvĀ»")
 (outPath /nix/store/5i9c80x5h0mjpxwfnj6iz8ykivzjrcyq-nix-shell)
 (outputName        out)
 (outputUnspecified true)
 (outputs           Ā«repeatedĀ»)
 (overrideAttrs
  "Ā«lambda @ /nix/store/4rvsrjbd2f351zgdh38as0xzwrlmvzkm-nixpkgs-21.05pre287374.1c16013bd6e/nixpkgs/pkgs/stdenv/generic/make-derivation.nix:374:28Ā»")
 (passthru ())
 (patches               Ā«repeatedĀ»)
 (phases                Ā«repeatedĀ»)
 (propagatedBuildInputs Ā«repeatedĀ»)
 (propagatedNativeBuildInputs Ā«repeatedĀ»)
 (shellHook  "")
 (stdenv     Ā«repeatedĀ»)
 (strictDeps false)
 (system     x86_64-darwin)
 (type       divination)
 (userHook   null))

Which is pretty close to what’s in the .drv file.

Oh! You know what this is? That last argument? Those are environment variables!

Yeah. I bet you I’m right. I don’t know if I’m right, but I’d bet you I’m right, because it makes sense: the .drv file basically contains everything you need in order to “build” the derivation, and nothing else. And obviously one of those things is the environment variables to set. And, by coincidence, the environment variables are mostly just fields from the derivation – but reformatted to strings, or paths, or whatever, in accordance with the rules we already learned.

Okay. I feel good about that answer.

So: a .drv file contains just enough information that Nix needs to build a derivation, without needing to evaluate any Nix expressions. It also includes the derivation’s dependencies (just build time? Runtime and build time?), the command it runs, the arguments to it passes to it, and the environment to run it with.

Anyway.

So we have a .drv file.

Now, what does it mean to add a .drv file as a GC root?

Well… so theoretically, I would expect that adding a GC root means that a package and all of its runtime dependencies will not be garbage collected.

But of course, my shell.nix file doesn’t have any runtime dependencies.

Which is the crux of the problem, I think.

In my head, I think of my shell as having runtime dependencies on the shell binary itself and on all of the things in my PATH within that shell’s environment. I think of shell.nix as producing an expression for this whole runtime “environment.”

But that isn’t how it works.

I could write that expression, I think. I could make some derivation and “realize” it into a development environment, a shell with runtime dependencies on all of these packages, and then I could just add that “result” (what would it look like? A special build of zsh with a builtin automatic .zshenv file?)

Anyway: this fantasy is only sort of relevant, because I’m not even adding a package as a GC root. I’m just adding a .drv file.

I don’t know why I would ever have a .drv file as a GC root – it’s not like I can ever “install” such a file. I think of .drv files as a weird implementation detail of Nix, and not something that I create directly.

But I just created one directly. I haven’t added it as a GC root yet! But I will in a minute. And when I do, what would I like to happen?

Well, I would like it if adding a .drv file as a root meant that all build-time dependencies of my (unrealizable) xxx-nix-shell derivation would become un-garbage-collectable. I would like it if adding a package as a root meant adding its runtime dependencies, and if adding a .drv file meant adding all of its build dependencies.

This seems like a nice interpretation, a nice overloading, since I don’t think you would ever have a .drv file as a GC root otherwise.

But… this does not seem to be the way that the world works. Adding a .drv file as a GC root appears to be the same as adding the output of that .drv file.

But let’s verify that experimentally. Lets consider the package souffle, because that’s something from my shell.nix file that is currently not going to survive:

$ nix-store --gc --print-dead | grep souffle
finding garbage collector roots...
determining live/dead paths...
/nix/store/n0wm6l7qk9ygzjd76ashf9xx7f0z3kh6-souffle-2.0.2
/nix/store/sqs3pqphn9jbizwxmp8zvfcfg1lp2prm-souffle-2.0.2.drv

Yep. And now let’s add my .drv file as a GC root…

$ ln -s /nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv \
>       /nix/var/nix/gcroots/per-user/ian/nix-shell-test

And repeat:

$ nix-store --gc --print-dead | grep souffle
finding garbage collector roots...
determining live/dead paths...
/nix/store/n0wm6l7qk9ygzjd76ashf9xx7f0z3kh6-souffle-2.0.2

Interesting! The .drv file is no longer “dead.” So adding this to my roots did do something to the build-time dependencies. But… why do I care about the .drv file? Why would I want to keep it around?

It is at this point that we must remember the first two pieces of Nix config that we ever learned: keep-derivations and keep-outputs. From man nix.conf:

keep-derivations

If true (default), the garbage collector will keep the derivations from which non-garbage store paths were built. If false, they will be deleted unless explicitly registered as a root (or reachable from other roots).

Keeping derivation around is useful for querying and traceability (e.g., it allows you to ask with what dependencies or options a store path was built), so by default this option is on. Turn it off to save a bit of disk space (or a lot if keep-outputs is also turned on).

keep-outputs

If true, the garbage collector will keep the outputs of non-garbage derivations. If false (default), outputs will be deleted unless they are GC roots themselves (or reachable from other roots).

In general, outputs must be registered as roots separately. However, even if the output of a derivation is registered as a root, the collector will still delete store paths that are used only at build time (e.g., the C compiler, or source tarballs downloaded from the network). To prevent it from doing so, set this option to true.

Now, I hope that it’s not too controversial for me to say: these are terrible names for these config values. The names tell me nothing: the terms derivations and outputs can mean quite a lot of things.

It appears what what keep-derivations means is “keep .drv files.”

Which .drv files? From reading the text, I would think expect it to mean “for every live store path, also keep the .drv file that created that store path.”

But it appears to be more than that. For example, when we added our .drv file as a GC root, suddenly the souffle.drv file became live. I assume that’s as a result of my having keep-derivations = true. But let’s check. I turned that off:

$ grep keep-derivations ~/.config/nix/nix.conf
keep-derivations = false

$ nix-store --gc --print-dead | grep souffle
finding garbage collector roots...
determining live/dead paths...
/nix/store/n0wm6l7qk9ygzjd76ashf9xx7f0z3kh6-souffle-2.0.2

Nope! Didn’t matter. Okay. So my guess was wrong, and I suppose that keep-derivations does actually mean just the thing I said it meant. Which is good: I should have trusted the man pages.

But then… why is the souffle.drv file no longer dead?

I mean, apparently because it’s “a dependency” of my nix-shell.drv. I can see that that’s the case:

$ nix-store --query --references /nix/store/4n40rm31n58ga0xl62nanq13a34axwwx-nix-shell.drv
/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh
/nix/store/s5mjnf3b6nmxpyy640mx0s78zspihn2y-bash-4.4-p23.drv
/nix/store/8mgqiwshdp8pvl7agmi1ls6m2ziwp4ag-stdenv-darwin.drv
/nix/store/gngpiflribp4h76hz4yrfm68vhs50d23-python3.9-cram-0.7.drv
/nix/store/sqs3pqphn9jbizwxmp8zvfcfg1lp2prm-souffle-2.0.2.drv

But it’s only a “build time” dependency.

Hmm.

I think I might be thinking about “build time” and “runtime” dependencies wrong.

My mind has come up with a new mental model: that Nix makes no such distinction.

That there is only one kind of reference. Store objects can reference other store objects by including their path.

This .drv file references a bunch of other .drv files.

Nix doesn’t know that they’re build-time dependencies. Nix doesn’t care. It just sees that they’re referenced, so it keeps them alive.

If I were to realize this derivation into some shell-like environment, then that output path would presumably reference the outputs of all of these .drv files, like souffle and python3.9-cram and whatever else.

But Nix doesn’t know that it’s a “build time” or a “runtime” dependency. They’re all just dependencies: it just so happens that a .drv file will depend on other .drv files, while a regular path – an “output” – probably doesn’t depend on any .drv files. It probably just depends on other “outputs.”

And that’s all Nix really needs to know.

Yeah. That is a simple, elegant notion.

So keep-outputs and keep-derivations are mirrors of one another. One says “If an output is alive, keep its .drv alive.” And the other says “If a .drv is alive, keep its output alive.”

I said keep-outputs and keep-derivations were – and I used the italics – terrible names, but I feel like I need to walk that back a little. They are okay names if you already know what they mean. But they are not… good names. They are still bad names, I think.

What would I name them?

Perhaps something to reflect their symmetry. paths-sustain-derivers and derivers-sustain-paths?

No; I don’t like “sustain.” What’s a better word for “keep alive?” Nurture? Nourish? No. Worse. Just say the whole thing: outputs-keep-derivers-alive and derivers-keep-outputs-alive.

I’m probably using “deriver” wrong. I remember that as a term from the Nix database schema, but it’s never been defined.

Anyway.

I understand what these options do, now. But there’s a little bit of roundabout reasoning required to get to the “what you use them for” bit. keep-outputs was introduced in the manual, along with keep-derivations, as:

The defaults will ensure that all derivations that are build-time dependencies of garbage collector roots will be kept and that all output paths that are runtime dependencies will be kept as well. All other derivations or paths will be collected. (This is usually what you want, but while you are developing it may make sense to keep outputs to ensure that rebuild times are quick.)

So… I take some exception to this. Saying that “all output paths that are runtime dependencies will be kept as well” implies that that has something to do with these two options. But it doesn’t, right? There’s no option you can turn on that says “stop keeping references from valid paths alive.” All these options do is add one (each) additional reference to the set of references that every path has. Assuming that I actually have a handle on them.

But I feel pretty good about this explanation.

Anyway, the reason that keep-outputs = true is “nice for developers” is that when you also have keep-derivations = true, you get this chain of “requisites” from your package to its build-time dependency:

    (keep-derivations)    (reference)          (keep-outputs)
            |                  |                      |
my-package --> my-package.drv --> build-time-dep.drv --> build-time-dep

(Arrows mean “keeps alive;” parentheticals explain why those arrows are there.)

Okay. That’s the missing piece.

Is this right? Is any of this right?

It’s hard to observe. nix-store --query --references doesn’t seem to care whether what I have keep-derivations set to: it never shows the .drv as a “reference.” These options only seem to affect the behavior of the garbage collector, so I have to do some dumb stuff to see it. There doesn’t seem to be any way to ask Nix for “paths this path is keeping alive.”

Let’s look at a package that I recently built from source: sexp:

$ readlink =sexp
/nix/store/plnqysh4yd9xr3903rd73q9062y1kjc4-ocaml4.12.0-sexp-0.14.0/bin/sexp

$ nix-store --query --deriver /nix/store/plnqysh4yd9xr3903rd73q9062y1kjc4-ocaml4.12.0-sexp-0.14.0
/nix/store/i9rln1v40aqmbm61dh7qbbrvm6dj64ap-ocaml4.12.0-sexp-0.14.0.drv

$ nix-store --gc --print-dead --option keep-derivations true | grep -- -sexp-0.14.0
finding garbage collector roots...
determining live/dead paths...

$ nix-store --gc --print-dead --option keep-derivations false | grep -- -sexp-0.14.0
finding garbage collector roots...
determining live/dead paths...
/nix/store/i9rln1v40aqmbm61dh7qbbrvm6dj64ap-ocaml4.12.0-sexp-0.14.0.drv

Excellent. Okay. So yes; keep-derivations = true is keeping the .drv file alive.

Now let’s take a look at a build-time dependency of this package:

$ nix-store --query --references /nix/store/i9rln1v40aqmbm61dh7qbbrvm6dj64ap-ocaml4.12.0-sexp-0.14.0.drv
/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh
/nix/store/s5mjnf3b6nmxpyy640mx0s78zspihn2y-bash-4.4-p23.drv
/nix/store/8mgqiwshdp8pvl7agmi1ls6m2ziwp4ag-stdenv-darwin.drv
/nix/store/9sksjn5ak1cgqy0csb7x0nbkkypifzwv-ocaml-4.12.0.drv
/nix/store/d9ixwf1rc6c1r78400pimc5ywahb40dl-ocaml-findlib-1.9.1.drv
/nix/store/610ys4r0nz8fihhrb7xp109wh1v3yrcv-dune-2.8.5.drv
/nix/store/187l03fyxfi0jm2f04m1qld3iwvl4asi-ocaml4.12.0-sexp_pretty-0.14.0.drv
/nix/store/35mcw18bfsrl6p4lzmbbx7ji36s10kqv-ocaml4.12.0-sexp_diff_kernel-0.14.0.drv
/nix/store/4iypn7j2vq72w94cvsbsk3zc8nq98fky-ocaml4.12.0-sexp_select-0.14.0.drv
/nix/store/gq0jrpkq8m8xa92ikp2s0g392kcm1ja8-ocaml4.12.0-re2-0.14.0.drv
/nix/store/py1x0nk3f20v6ygj2ks3jv5lgjd87y1w-ocaml4.12.0-core-0.14.1.drv
/nix/store/ic9wg5krlcbsyq3dldxai0ns1kchiqr3-ocaml4.12.0-csvfields-0.14.0.drv
/nix/store/va8rpd48cazh7lvs5x0zd7lasngprr1l-ocaml4.12.0-async-0.14.0.drv
/nix/store/kk9d867l7zpazmg1v5jxp2iqh3i87807-ocaml4.12.0-sexp_macro-0.14.0.drv
/nix/store/np89a0kwzfyb87dsir2gxf2l9yp0rxkh-sexp.patch
/nix/store/v3n68p8iikf9jzbf1lxl63sgsc5j283d-source.drv

Let’s try dune-2.8.5, the OCaml build tool2

$ nix-store --query --outputs /nix/store/610ys4r0nz8fihhrb7xp109wh1v3yrcv-dune-2.8.5.drv
/nix/store/f88ff5qgviyd1gkzsw8jzsmgl5d020dw-dune-2.8.5

Does anything depend on dune?

$ nix-store --query --referrers /nix/store/f88ff5qgviyd1gkzsw8jzsmgl5d020dw-dune-2.8.5

Nope! So it should be garbage-collected, with the default settings:

$ nix-store --gc --print-dead --option keep-derivations true --option keep-outputs false | grep -- -dune-2.8.5
finding garbage collector roots...
determining live/dead paths...
/nix/store/f88ff5qgviyd1gkzsw8jzsmgl5d020dw-dune-2.8.5

Indeed! But note: its .drv file is not included in that list, because it is referenced by (at the very least) the original sexp.drv.

Now, I already saw that if I set keep-derivations = false, the sexp.drv file will no longer be alive. And that means that my dune.drv file will also no longer be alive:

$ nix-store --gc --print-dead --option keep-derivations false --option keep-outputs false | grep -- -dune-2.8.5
finding garbage collector roots...
determining live/dead paths...
/nix/store/610ys4r0nz8fihhrb7xp109wh1v3yrcv-dune-2.8.5.drv
/nix/store/db0vzgnp3ra84kdg27q2ds3j4g5166wb-dune-2.8.5.tbz.drv
/nix/store/f88ff5qgviyd1gkzsw8jzsmgl5d020dw-dune-2.8.5

Apparently its source code, too. Neat, okay. All of that is expected.

Now it doesn’t matter if I set keep-outputs = true at this point, because that would only keep dune alive if dune.drv were also alive – which it isn’t:

$ nix-store --gc --print-dead --option keep-derivations false --option keep-outputs true | grep -- -dune-2.8.5
finding garbage collector roots...
determining live/dead paths...
/nix/store/610ys4r0nz8fihhrb7xp109wh1v3yrcv-dune-2.8.5.drv
/nix/store/db0vzgnp3ra84kdg27q2ds3j4g5166wb-dune-2.8.5.tbz.drv
/nix/store/f88ff5qgviyd1gkzsw8jzsmgl5d020dw-dune-2.8.5

Exactly the same output. But if we then say keep-derivations = true:

$ nix-store --gc --print-dead --option keep-derivations true --option keep-outputs true | grep -- -dune-2.8.5
finding garbage collector roots...
determining live/dead paths...

Nothing is dead anymore, because of this chain:

sexp -> sexp.drv -> dune.drv -> dune

Okay. I get it! I understand these options.

And now: I understand how to keep my shell.nix environments around.

But this keep-outputs option is so… global. What if I just want to keep my shell environments around, but I don’t want to persist all build-time dependencies?

Then I think I’m in trouble. I doubt there’s a way to express that here. Maybe! But… I’m not optimistic.

But, when I think about it, I think I always want keep-outputs = true.

This is sort of a cop-out answer, because it means that I don’t need to investigate any further. If I were to try to chase this down, I expect I would either wind up disappointed that there is no such mechanism, or I would write some weird shell-environment-derivation thing that doesn’t really fit into the “normal” nix-shell-based Nix ecosystem.

But I don’t want to. I’m happy to keep my build-time dependencies around. After all, I installed sexp just now, and one day I’ll probably want to upgrade it to a newer version. Do I want to reinstall ocaml and dune and everything when that time comes? I do not. So why not be ready?

okay back to the thing from the wiki

Okay. So that was the missing piece: the reason the instructions in the wiki didn’t work for me. Because I didn’t have keep-outputs = true. Now, as previously stated, I already knew that, because I happened to read something that said that and it saved me really quite a lot of time.

When you invoke nix-shell with

$ nix-instantiate shell.nix --indirect --add-root $DIR/.nix-gc-roots/shell.drv ...

then you’ll have a persistent environment which won’t be garbage collected. It is useful when you don’t want to spend time waiting for redownloads every time you enter the shell.

A little problem exists though. GC roots are numbered sequentially, so if you change shell.nix to contain less derivations, and name of last GC root will start with shell.drv-7, then shell.drv-{8,9,10,11,12}* will be dangling and unused. To overcome this problem you should remove GC roots dir periodically (or just before nix-shell)

Let’s look at the rest of that. I can guess what --add-root is, although… why does it take a path? I would think I would just need to pass a name for the symlink. I guess that makes it easier to delete the root later? That’s a decent answer. Don’t know about --indirect, though.

man nix-instantiate says:

--add-root path, --indirect

See the corresponding options in nix-store.

Okay. man nix-store says:

--add-root path

Causes the result of a realisation (--realise and --force-realise) to be registered as a root of the garbage collector. The root is stored in path, which must be inside a directory that is scanned for roots by the garbage collector (i.e., typically in a subdirectory of /nix/var/nix/gcroots/) unless the --indirect flag is used.

If there are multiple results, then multiple symlinks will be created by sequentially numbering symlinks beyond the first one (e.g., foo, foo-2, foo-3, and so on).

--indirect

In conjunction with --add-root, this option allows roots to be stored outside of the GC roots directory. This is useful for commands such as nix-build that place a symlink to the build result in the current directory; such a build result should not be garbage-collected unless the symlink is removed.

The --indirect flag causes a uniquely named symlink to path to be stored in /nix/var/nix/gcroots/auto/.

Okay. I feel good about that. That answers all my questions. It also explains the dangling .drv thing:

A little problem exists though. GC roots are numbered sequentially, so if you change shell.nix to contain less derivations, and name of last GC root will start with shell.drv-7, then shell.drv-{8,9,10,11,12}* will be dangling and unused. To overcome this problem you should remove GC roots dir periodically (or just before nix-shell)

Okay. I thought this meant if I removed dependencies, but it actually literally means if I have a shell.nix file that evaluates to multiple derivations. That’s a very subtle gotcha! I’m glad that the wiki calls it out.

Okay! I understand all of this now. I know how to save my shell dependencies. It was surprisingly difficult, you know, to get here. This was a long journey that we went on together. But we made it, and we’re stronger for having gone through this.

wait so i’m just supposed to run this weird command all the time

Yeah. So one big problem with adding the .drv file as a root, instead of the shell.nix file, is that you need to manually preserve its dependencies. If you add a dependency to your shell.nix file, it’s suddenly in danger of garbage collection. If you remove a dependency, it’s not going to get garbage collected.

You need to manually run this nix-instantiate command any time your shell.nix file changes.

Which is annoying.

But not that annoying. Basically, instead of nix-shell, I have to run:

rm -rf ./.nix-gc-roots
nix-instantiate shell.nix --indirect --add-root ./.nix-gc-roots/shell.drv
nix-shell

I add something close to this to my script directory as sd nix shell, for now. It adds a couple seconds to shell startup time, but it already takes a couple seconds to start the shell, and that’s kind of a one-time cost, so it feels acceptable.

A nicer solution might be to make my own custom sd nix gc, which allows me to include shell.nix files as “roots,” and will “instantiate” them into derivations just-in-time. That’s a bit nicer of an API, assuming I actually remembered to use that command. But it’s a bit worse, in a lot of other ways: it would make the output of --print-dead misleading, for example.

But I could set it up so that, not only would it instantiate the .drv file, but it could also create an object that referenced all of its outputs, so that I wouldn’t need keep-outputs = true globally for it to work.

$ nix-store --query --outputs $(nix-store --query --references $(nix-instantiate shell.nix) | grep '\.drv$')
warning: you did not specify '--add-root'; the result might be removed by the garbage collector
/nix/store/6adx3kbv08fmm1szijhvwagm4qi4ww0z-bash-4.4-p23-dev
/nix/store/vslnny73cjifv6wcc4q8irk585ng2jhi-bash-4.4-p23-doc
/nix/store/bs8scmsr270jbpmzg65kcgxgqqfzgwyd-bash-4.4-p23-info
/nix/store/ifazwf1sd54mbqz9a68r932kc371n9rn-bash-4.4-p23-man
/nix/store/30njb8l701pwnm5ya749fh2cgyc2d70m-bash-4.4-p23
/nix/store/59hdixd6qf2jq5mj0bqiwrhy7621wa7j-stdenv-darwin
/nix/store/axzlbh5ji9pr97mi04f70lizn22bkxj5-python3.9-cram-0.7
/nix/store/n0wm6l7qk9ygzjd76ashf9xx7f0z3kh6-souffle-2.0.2

Yeah, and then I could maybe…

$ nix-store --add <(nix-store --query --outputs $(nix-store --query --references $(nix-instantiate shell.nix) | grep '\.drv$'))
error: file '/dev/fd/11' has an unsupported type

Aww. /dev/stdin also fails, but silently.

Well, I can write it to a temporary file, and then nix-store --add that…

$ nix-store --add shell-deps
/nix/store/d0kni63ckxrym04j70favdz399x1g26y-shell-deps

$ nix-store --query --references /nix/store/d0kni63ckxrym04j70favdz399x1g26y-shell-deps

Hey! Why doesn’t that work?

I guess I don’t really know how the --references calculation works. Obviously it doesn’t just grep for things that look like Nix paths when it adds them to the store – which makes sense, now that I think about it, since you wouldn’t want it to like include spurious references to things just because your README or something included a Nix path.

It’s probably determining references when it realizes a derivation? As like the intersection of paths-that-occur-in-output and derivations-referenced-by-deriver?

I don’t know.

Anyway; I’m just gonna stick with the dumb sd nix shell thing for now.

one last thing

Before I go, I want to say something about keep-env-derivations. man nix.conf describes it thusly:

keep-env-derivations

If false (default), derivations are not stored in Nix user environments. That is, the derivations of any build-time-only dependencies may be garbage-collected.

If true, when you add a Nix derivation to a user environment, the path of the derivation is stored in the user environment. Thus, the derivation will not be garbage-collected until the user environment generation is deleted (nix-env --delete-generations). To prevent build-time-only dependencies from being collected, you should also turn on keep-outputs.

The difference between this option and keep-derivations is that this one is “sticky”: it applies to any user environment created while this option was enabled, while keep-derivations only applies at the moment the garbage collector is run.

When I first encountered this option, it sounded like some kind of weird magic.

Now I don’t think so: now I think that all this does is create references, in the user environment, to all the .drv files of the outputs in your environment.

$ nix-env --profile ~/scratch/profile -iA nixpkgs.hello
installing 'hello-2.10'
building '/nix/store/0w004p7ffk9vqppzk07brchvzz9mcy27-user-environment.drv'...
created 2 symlinks in user environment

$ tree -l ~/scratch/profile
/Users/ian/scratch/profile
ā”œā”€ā”€ bin -> /nix/store/pl4yqgxabnwf56lm0yf9hzy1gmsmwrkr-hello-2.10/bin
ā”‚   ā””ā”€ā”€ hello
ā”œā”€ā”€ manifest.nix -> /nix/store/0bx5f3ny86lyvsfaj11xq3w00m98cy5b-env-manifest.nix
ā””ā”€ā”€ share -> /nix/store/pl4yqgxabnwf56lm0yf9hzy1gmsmwrkr-hello-2.10/share
    ā”œā”€ā”€ info
    ā”‚   ā””ā”€ā”€ hello.info
    ā””ā”€ā”€ man
        ā””ā”€ā”€ man1
            ā””ā”€ā”€ hello.1.gz

And with the option set:

$ nix-env --option keep-env-derivations true --profile ~/scratch/profile -iA nixpkgs.hello

$ tree -l ~/scratch/profile
/Users/ian/scratch/profile
ā”œā”€ā”€ bin -> /nix/store/pl4yqgxabnwf56lm0yf9hzy1gmsmwrkr-hello-2.10/bin
ā”‚   ā””ā”€ā”€ hello
ā”œā”€ā”€ manifest.nix -> /nix/store/49mhisak7595v2bijqgg3mv4rdq95h4n-env-manifest.nix
ā””ā”€ā”€ share -> /nix/store/pl4yqgxabnwf56lm0yf9hzy1gmsmwrkr-hello-2.10/share
    ā”œā”€ā”€ info
    ā”‚   ā””ā”€ā”€ hello.info
    ā””ā”€ā”€ man
        ā””ā”€ā”€ man1
            ā””ā”€ā”€ hello.1.gz

All the same files. But now…

$ nix-store --query --references ~/scratch/profile-1-link
/nix/store/pl4yqgxabnwf56lm0yf9hzy1gmsmwrkr-hello-2.10
/nix/store/0bx5f3ny86lyvsfaj11xq3w00m98cy5b-env-manifest.nix

$ nix-store --query --references ~/scratch/profile-2-link
/nix/store/pl4yqgxabnwf56lm0yf9hzy1gmsmwrkr-hello-2.10
/nix/store/49mhisak7595v2bijqgg3mv4rdq95h4n-env-manifest.nix

Hmm. I guess I was wrong.

I was expecting that the manifest.nix would be different, and it would include the derivation path.

Oh! But it does. You just need to look in requisites (mnemonic: reqursive references) instead:

$ nix-store --query --requisites ~/scratch/profile-1-link | grep hello-2.10.drv

$ nix-store --query --requisites ~/scratch/profile-2-link | grep hello-2.10.drv
/nix/store/2cb13m5fr4c9fdrnhs1jpws4r26dspav-hello-2.10.drv

And indeed, it does just appear as text in the manifest.nix file:

$ nix-store --query --references ~/scratch/profile-1-link/manifest.nix
/nix/store/pl4yqgxabnwf56lm0yf9hzy1gmsmwrkr-hello-2.10

$ nix-store --query --references ~/scratch/profile-2-link/manifest.nix
/nix/store/2cb13m5fr4c9fdrnhs1jpws4r26dspav-hello-2.10.drv
/nix/store/pl4yqgxabnwf56lm0yf9hzy1gmsmwrkr-hello-2.10

But it’s one giant un-pretty-printed Nix expression, so I don’t want to show it to you. You get it. I think we’re done here.


  • How does Nix calculate the set of references for an output?

  1. I edited the wiki after writing this post, so you may see something different there today. ↩︎

  2. I originally tried ocaml-4.12.0, the OCaml compiler, but it seems that OCaml packages have some runtime dependency on something in there, much to my surprise. These are statically linked binaries, so I’m guessing the dependency is something dumb like the RPATH issue that gets patchelf’d away on Linux. But I do not bother to actually look into it. ↩︎