Day 71 — Evaluator internals
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.
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.aYou 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 encounteredPattern B — Mutual recursion without a base case
let
a = b + 1;
b = a + 1;
in aPattern C — Attrset self-reference (legal shape, dangerous force)
rec {
a = 1;
b = a + 1; # OK
# c = c; # infinite if forced
}rec is not evil; forcing a cycle is.
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?
helloPrefer explicit attr access in teaching examples; with is a readability tax on recursion debugging.
Theory 3 — Debugging toolkit
builtins.trace
builtins.trace "here" valuePrints 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: latelib.debug.traceVal / traceSeq / traceValSeq (nixpkgs)
When you have lib from nixpkgs:
lib.debug.traceVal someAttr
lib.debug.traceValSeqN 2 nestedPrefer 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.hostNameFor 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 |
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 encounteredFixed
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/day71Lab 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.nixRecord 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:
- Direct
letcycle
- Mutual
a/bcycle
recset 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/nullIf eval is slow, binary-search:
- Comment out recently added modules (or use a slimmed
modules = [ ... ]copy).
- Force smaller slices with
nix eval …config.services.<name>.enable.
- Add a temporary
lib.traceonly 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 --jsonNote: 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
seqvsdeepSeqdemo committed
- Trace notes on one real flake option
NOTES.mdwith three personal gotchas
- No permanent
deepSeqleft 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.