Day 71 — Evaluator internals

Updated

July 30, 2026

Day 71 — Evaluator internals

Stage VII · ~4h (theory-heavy)
Goal: Explain thunks, force evaluation intentionally, reproduce and fix infinite recursion, and use trace / tryEval / seq / deepSeq without cargo-culting them.

Note

This day is language/evaluator depth. Prefer a disposable nix repl and tiny pure expressions before touching your host flake. A bad recursive module option can hang eval of the whole system.

Why this day exists

By Stage VI you already use laziness every rebuild: modules only force what activation needs. When eval explodes—infinite recursion encountered, mysterious null, or a multi-minute wait before any build starts—the bug is often a thunk cycle or accidental full-tree force, not a broken package.

Internals last (syllabus rule) means you can now map symptoms → mechanisms.


Theory 1 — Evaluation is a graph of thunks

Values vs thunks

Nix does not compute every subexpression when you write it. An unevaluated expression is a thunk: a promise to compute later, holding the expression body plus an environment of free variables.

Concept Meaning
Thunk Deferred computation; may be forced 0 or 1 times (then memoized)
WHNF Weak head normal form: outer constructor known ({…}, […], lambda, int, …) without forcing nested fields
NF Full normal form: nested values forced recursively
Memoization After force, the thunk is replaced by the result (shared)
let
  x = builtins.trace "forcing x" (1 + 1);
  y = { a = x; b = x; };
in
  y.a

You should see "forcing x" once. Both a and b share the same thunk identity until forced.

Why modules love laziness

# Sketch of the idea — not the real module system
config = {
  services.foo.enable = true;
  # huge attrset of options you never touch...
};
# Only options that are *demanded* by evaluation of the final system get forced.

If every option were strict, evaluating a NixOS config would always pay the full cost of every default expression, including unused services.

Eager edges you already know

Construct Eager? Notes
import ./file.nix Path resolve + parse/eval of the file File body still may return lazy values
Function application f x Forces f to a function; forces args per function body Body of f decides strictness
&& / \|\| Short-circuit Second arm may never force
if c then a else b Forces c Only one branch forces
builtins.seq a b Forces a to WHNF, returns b Classic “strictness injection”
builtins.deepSeq a b Forces a fully, returns b Expensive; tests and asserts
builtins.trace msg v Forces nothing about v except returns it Side effect for debugging

Theory 2 — Infinite recursion patterns

Pattern A — Direct self-reference

let x = x + 1; in x
# error: infinite recursion encountered

Pattern B — Mutual recursion without a base case

let
  a = b + 1;
  b = a + 1;
in a

Pattern D — Module / option cycles (production shape)

Classic failure:

# module A
config.services.foo.settings.bar = config.services.baz.settings.qux;

# module B
config.services.baz.settings.qux = config.services.foo.settings.bar;

Or subtler:

options.my.paths.dataDir = lib.mkOption {
  type = lib.types.path;
  default = config.my.paths.root + "/data";  # forces config while defining defaults
};

Rule of thumb: defaults and apply functions should depend on options (or pure inputs), not on deep config that re-enters the same option.

Pattern E — with + shadowing confusion

let pkgs = { hello = "ok"; }; in
with pkgs;
let hello = hello; in  # which hello?
hello

Prefer explicit attr access in teaching examples; with is a readability tax on recursion debugging.


Theory 3 — Debugging toolkit

builtins.trace

builtins.trace "here" value

Prints to stderr when the trace expression itself is forced (i.e. when something needs value through the trace).

builtins.tryEval

builtins.tryEval (throw "boom")
# → { success = false; value = false; }  # value is dummy on failure

builtins.tryEval 42
# → { success = true; value = 42; }

Use for soft probing—not for swallowing production errors silently.

seq vs deepSeq

builtins.seq { a = throw "late"; } "outer-ok"
# → "outer-ok"   # only WHNF of the attrset

builtins.deepSeq { a = throw "late"; } "never"
# → error: late

lib.debug.traceVal / traceSeq / traceValSeq (nixpkgs)

When you have lib from nixpkgs:

lib.debug.traceVal someAttr
lib.debug.traceValSeqN 2 nested

Prefer library helpers over ad-hoc nested trace once you are in a flake with pkgs/lib.

NIX_SHOW_STATS and evaluator metrics

NIX_SHOW_STATS=1 nix eval --impure --expr '1+1'

Look for counts of thunks, allocations, and GC—useful when comparing “eval is slow” hypotheses, not as a daily dashboard.

nix-instantiate --eval / nix eval

nix eval --expr 'let x = x; in x'          # expect infinite recursion
nix eval --json .#nixosConfigurations.lab.config.networking.hostName

For module cycles, force one leaf option rather than the entire config.


Theory 4 — When to force (and when not to)

Goal Tool Risk
Ensure side-effect config is checked early assertions / warnings in modules Good default
Fail fast if a secret path missing assert in module, or activation script Prefer module-time when pure
Make a function strict in an arg seq at the start of the body Can defeat sharing
Serialize entire config for tests deepSeq or lib.generators.toJSON Huge cost if done casually
“Fix” mysterious laziness bugs with deepSeq everywhere Don’t Hides design issues; tanks eval
Warning

deepSeq is a diagnostic scalpel and a test hammer—not a seasoning for every module.


Worked example — Construct, diagnose, fix

Broken

# day71-broken.nix
rec {
  root = "/var/lib/app";
  data = "${data}/files";   # oops: meant root
}
nix eval -f day71-broken.nix data
# infinite recursion encountered

Fixed

rec {
  root = "/var/lib/app";
  data = "${root}/files";
}

Module-shaped fix

Prefer:

{ lib, config, ... }:
{
  options.my.app.root = lib.mkOption {
    type = lib.types.path;
    default = "/var/lib/app";
  };
  options.my.app.dataDir = lib.mkOption {
    type = lib.types.path;
    default = "${config.my.app.root}/files";  # depends on sibling option via config — OK if no cycle
  };
}

If root’s default ever depended on dataDir, you create a cycle. Draw a DAG of option dependencies when stuck.


Lab 0 — Workspace

mkdir -p ~/lab/90daysofx/02-nixos/day71
cd ~/lab/90daysofx/02-nixos/day71

Lab 1 — Watch memoization

Create thunks.nix:

let
  expensive = builtins.trace "EXPENSIVE" (builtins.foldl' (a: b: a + b) 0 (builtins.genList (i: i) 100));
  bag = { x = expensive; y = expensive; };
in
{
  first = bag.x;
  second = bag.y;
}
nix eval -f thunks.nix first
nix eval -f thunks.nix second
nix eval -f thunks.nix

Record how many times EXPENSIVE prints in each case. Explain sharing in one paragraph in NOTES.md.


Lab 2 — Infinite recursion zoo

Create cycles.nix with three named attributes that each fail differently when forced:

  1. Direct let cycle
  2. Mutual a/b cycle
  3. rec set cycle

Force each with nix eval -f cycles.nix <name> and paste the error fingerprints into NOTES.md.

Then fix all three; leave the broken versions commented for teaching.


Lab 3 — seq vs deepSeq

# force.nix
let
  t = {
    a = builtins.trace "a" 1;
    b = builtins.trace "b" (throw "b-fail");
  };
in {
  onlyWhnf = builtins.seq t "ok-whnf";
  full = builtins.deepSeq (builtins.removeAttrs t ["b"]) "ok-deep";
  # fullBoom = builtins.deepSeq t "never";
}

Evaluate onlyWhnf and full. Uncomment fullBoom once and capture the error. Write when WHNF is enough for module work.


Lab 4 — Trace a tricky real eval (your flake)

Pick one leaf from your lab host:

cd /path/to/your/flake
nix eval .#nixosConfigurations.<host>.config.networking.hostName
# or a service option you care about
time nix eval .#nixosConfigurations.<host>.config.system.build.toplevel --raw >/dev/null

If eval is slow, binary-search:

  1. Comment out recently added modules (or use a slimmed modules = [ ... ] copy).
  2. Force smaller slices with nix eval …config.services.<name>.enable.
  3. Add a temporary lib.trace only around the suspect option default.

Document the path of the investigation—even if the conclusion is “eval cost is nixpkgs baseline.”


Lab 5 — Intentional soft failure with tryEval

# try.nix
{
  ok = builtins.tryEval (1 + 1);
  bad = builtins.tryEval (1 + "x");
}
nix eval -f try.nix --json

Note: type errors vs throw vs infinite recursion behave differently under tryEval—catalog what you observe.


Common gotchas

Symptom Theory link
infinite recursion encountered with no file Cycle in options/rec; force a smaller attr
Trace never prints Expression never forced; wrap the used value
Trace prints thousands of times You forced a list map; narrow the traced value
Eval hangs, no error Likely cycle; interrupt and bisect modules
deepSeq “fixed” a bug You hid a lazy design smell; redesign dependencies
with pkgs; + name collision Shadowing; drop with while debugging
Module default uses config of same option Define default from other options or pure constants

Checkpoint

  • Explain thunk / WHNF / memoization in your own words
  • Three infinite-recursion examples constructed and fixed
  • seq vs deepSeq demo committed
  • Trace notes on one real flake option
  • NOTES.md with three personal gotchas
  • No permanent deepSeq left in production modules

Commit

git add .
git commit -m "day71: evaluator thunks, cycles, and trace discipline"

Write three personal gotchas before continuing.

Tomorrow

Day 72 — Module system deep: full type catalog, mkMerge / priorities, and redesigning one module properly.