Module 05: Portability, Hermeticity, and Failure Modes¶
Module Position¶
flowchart TD
family["Reproducible Research"] --> program["Deep Dive Make"]
program --> module["Module 05: Portability, Hermeticity, and Failure Modes"]
module --> lessons["Lesson pages and worked examples"]
module --> checkpoints["Exercises and closing criteria"]
module --> capstone["Related capstone evidence"]
flowchart TD
purpose["Start with the module purpose and main questions"] --> lesson_map["Use the lesson map to choose reading order"]
lesson_map --> study["Read the lessons and examples with one review question in mind"]
study --> proof["Test the idea with exercises and capstone checkpoints"]
proof --> close["Move on only when the closing criteria feel concrete"]
Read the first diagram as a placement map: this page sits between the course promise, the lesson pages listed below, and the capstone surfaces that pressure-test the module. Read the second diagram as the study route for this page, so the diagrams point you toward the Lesson map, Exercises, and Closing criteria instead of acting like decoration.
Modules 01-04 teach correctness, parallel safety, determinism, and exact semantics. Module 05 turns that groundwork into a declared contract with auditable assumptions. You stop trusting your workstation, shell, locale, toolchain, and process, and you model what actually changes build meaning.
A hardened build system has two properties:
- it degrades intentionally (portability, feature gates, fallbacks), and
- it proves itself (convergence, equivalence, negative tests, and measurement).
Capstone exists here as corroboration. The local exercises should already make the boundary and hardening decisions understandable before you compare them to the reference build.
At a glance¶
| Focus | Learner question | Capstone timing |
|---|---|---|
| portability contract | "What tool and shell assumptions are truly required?" | inspect capstone contract files after you write one locally |
| modeled inputs | "Which environment facts change build meaning?" | compare with mk/stamps.mk after you understand one local stamp boundary |
| performance guardrails | "Am I measuring cost or guessing?" | use capstone guardrails once you can interpret one local measurement |
1) Table of Contents¶
- Table of Contents
- Learning Outcomes
- How to Use This Module
- Core 1 — Portability Contract and Version Gates
- Core 2 — Jobserver and Controlled Recursion
- Core 3 — Hermeticity by Modeling Inputs
- Core 4 — Performance Engineering
- Core 5 — Failure Modes, Migration Rubric, Canon, Anti-Patterns
- Capstone Sidebar
- Exercises
- Closing Criteria
2) Learning Outcomes¶
By the end, you can:
- Declare a Make contract: minimum GNU Make, required shell behavior, required tools, and controlled fallbacks.
- Prove parallel scheduling survives boundaries: jobserver tokens propagate, recursion is bounded, and diagnostics are readable.
- Model “hermetic enough” builds: inputs are explicit, stamps are convergent, attestations don’t poison artifacts.
- Measure and reduce Make overhead using profiling, trace volume, and parse-time cost control.
- Decide when Make is no longer the core tool using a rubric, and migrate via safe hybrids without losing your proof harness.
3) How to Use This Module¶
3.1 The five commands (your default loop)¶
Run these in every hardening incident:
- Confess what would run
3.2 Escalation ladder (when you’re stuck)¶
- Add
--warn-undefined-variablesto catch silent expansion bugs. - Add
-rRand.SUFFIXES:to eliminate built-in rule noise. - Add
--output-sync=recurseunder-jwhen logs become unusable. - Reduce to a minimal repro Makefile that demonstrates the failure in ≤20 lines.
3.3 “Correct” means (hardening edition)¶
A hardened build must satisfy all:
- Contracted environment: minimum GNU Make version, shell flags, and portability gates are explicit.
- Bounded recursion: if recursion exists, it is intentional, jobserver-aware, and depth-capped.
- Modeled inputs: toolchain identity and relevant env/flags are captured as stamps/manifests.
- Attestation doesn’t contaminate: metadata is produced without injecting entropy into equivalence artifacts.
- Measured: you can produce at least one trace-volume metric and one timed parse/decision metric.
- Proof harness exists: convergence + equivalence + at least one negative test.
This module should make the learner more disciplined, not more suspicious. The point is to declare assumptions precisely enough that surprises become diagnosable.
4) Core 1 — Portability Contract and Version Gates¶
Definition¶
A portability contract is a declared, testable boundary: which Make, which features, which shell, and which fallbacks.
Semantics¶
- GNU Make features are not “available”; they are conditional capabilities. You must gate them by
$(MAKE_VERSION). - “Portable shell” means POSIX
/bin/shbehavior. Don’t assume Bash features. -
Your contract must separate:
- required (fail fast) vs
- optional (warn + safe fallback).
Failure signatures¶
- Builds succeed locally but fail in CI (different Make versions / different shell).
- A feature silently does nothing (e.g.,
.WAITnot supported). - Paths break on Windows/MSYS2; timestamps skew; whitespace in
MAKEFLAGShandling breaks recursion.
Minimal repro¶
Repro: using .WAIT unconditionally.
On versions without .WAIT, this is not the barrier you think it is.
Fix pattern¶
Gate features and provide a deterministic fallback.
# mk/contract.mk — feature gates and version checks
# GNU Make ≥ 4.3 required (core contract for grouped targets and full patterns).
# MAKE_VERSION is provided by GNU Make. If missing, this Make is unsupported.
ifeq ($(origin MAKE_VERSION),undefined)
$(error This repository requires GNU Make (MAKE_VERSION not defined).)
endif
GNU_GE_4_3 := $(filter 4.3% 4.4% 5.%,$(MAKE_VERSION))
ifeq ($(GNU_GE_4_3),)
$(error GNU Make >= 4.3 required for grouped targets and full patterns (found $(MAKE_VERSION)).)
endif
# Feature probes (used for optional demos; do not change core correctness).
HAVE_GROUPED_TARGETS := $(filter 4.3% 4.4% 5.%,$(MAKE_VERSION))
HAVE_WAIT := $(filter 4.4% 5.%,$(MAKE_VERSION))
Proof hook¶
- Prove the contract trips on unsupported Make:
Verified portability matrix (keep as your living boundary)¶
Use this as the explicit “what we rely on” table.
| Feature | GNU Make | bmake | Windows notes |
|---|---|---|---|
| Jobserver tokens | ≥3.78 | Partial (-j local; no sub-pipes) |
WSL: OK; MSYS2: fragile spacing |
$(MAKE) propagation |
Full | Partial | WSL: OK; MSYS2: timestamp skew observed |
.WAIT |
≥4.4 | No (.ORDER instead) |
WSL: OK; MSYS2: skew risks |
Grouped targets &: |
≥4.3 | No | WSL: OK; MSYS2: path escaping pain |
.ONESHELL |
≥3.82 | No | WSL: OK; MSYS2: shell variance |
--trace |
≥4.3 (contracted) | No | WSL: OK; MSYS2: verbose output |
(If you claim more than this, you must attach an audit command.)
5) Core 2 — Jobserver and Controlled Recursion¶
Definition¶
The jobserver is GNU Make’s token system that enforces -jN across the build. Recursion is acceptable only when it participates in the same budget and stays observable.
Semantics¶
- Always invoke sub-make as
$(MAKE), nevermake.$(MAKE)is special: it propagates jobserver flags inMAKEFLAGS. - If the recipe is a recursive make, prefix with
+so it still runs under-n(dry-run semantics). -
Bound recursion by
MAKELEVEL:MAKELEVEL=0: topMAKELEVEL=1: first recursion- deeper than your budget → fail fast.
Failure signatures¶
make -j8behaves like-j1inside subdirectories (jobserver not propagated).make -n“skips” recursion targets (missing+).- Parallel builds hang (sub-make launched without jobserver tokens, or deadlocking on inherited auth).
Minimal repro¶
Repro A: jobserver lost
Repro B: dry-run lies
Fix pattern¶
# Depth cap
ifeq ($(MAKELEVEL),2)
$(error recursion too deep: MAKELEVEL=$(MAKELEVEL))
endif
.PHONY: thirdparty
thirdparty:
+@$(MAKE) -C thirdparty all --no-print-directory
Diagnostics (safe logging)
diag:
@echo "MAKELEVEL=$(MAKELEVEL)"
@echo "$(MAKEFLAGS)" | sed 's/--jobserver-auth=[^ ]*/--jobserver-auth=REDACTED/'
Proof hook¶
- Prove propagation:
6) Core 3 — Hermeticity by Modeling Inputs¶
Definition¶
Hermeticity here does not mean “rebuild the world in a sandbox”. It means: if an input can change an output, the graph models it—without turning metadata into entropy.
Semantics¶
- Stamps/manifests represent hidden inputs (tool versions, flags, env).
- Use order-only prerequisites (
|) when you need the stamp present but do not want it to trigger rebuilds. - Attestation is post-build metadata, not part of artifact identity, unless you explicitly choose otherwise.
Failure signatures¶
- You “added attestations” and now hashes differ every run (you injected non-determinism).
- Changing compilers doesn’t rebuild when it should (tool identity not modeled).
- Environment drift causes mysterious output changes (locale/timezone/paths not pinned or modeled).
Minimal repro¶
Repro: attestation contaminates artifact identity
all: app attest # WRONG: attest now participates in “all” artifact set
attest:
date > build/attest.txt
Fix pattern¶
A) Tool + env stamps as order-only, metadata separate
SHELL := /bin/sh
.SHELLFLAGS := -eu -c
export LC_ALL := C
export TZ := UTC
stamps/tool/cc.txt: FORCE | stamps/tool/
@$(CC) --version > $@
stamps/env.txt: FORCE | stamps/
@printf 'LC_ALL=%s\nTZ=%s\nPATH=%s\n' "$$LC_ALL" "$$TZ" "$$PATH" > $@
# app does not rebuild because stamps changed, but metadata can be produced deterministically.
app: main.o | stamps/tool/cc.txt stamps/env.txt
@$(CC) -o $@ main.o
attest: | stamps/tool/cc.txt stamps/env.txt
@cat stamps/tool/cc.txt stamps/env.txt > build/attest.txt
FORCE:
stamps/ stamps/tool/:
@mkdir -p $@
B) If flags/tool changes must force rebuild, make the stamp a real prerequisite of the compilation steps (That’s “correctness-first mode”; pick intentionally.)
Proof hook¶
- Prove attest does not poison equivalence artifacts:
7) Core 4 — Performance Engineering¶
Definition¶
Make performance issues are typically self-inflicted parse-time work: repeated wildcard, repeated patsubst, or $(shell ...) used as a compute engine.
Semantics¶
- Parse-time is the enemy: anything executed during expansion happens before the DAG is even scheduled.
-
“Fast enough” must be evidenced by:
- trace volume for representative goals (
make trace-count), - a timed parse/decision run (e.g.,
(/usr/bin/time -p make -n all >/dev/null) 2>&1), - and stable discovery (no churn).
- trace volume for representative goals (
Failure signatures¶
- “Make is slow” and the timed
make -n allrun shows most time is spent before the DAG is scheduled (often heavy function expansion or$(shell ...)). --trace | wc -lexplodes because the graph is defined redundantly.- Rebuild churn from unstable discovery lists.
Minimal repro¶
# WRONG: repeated expensive work
SRCS = $(wildcard src/*.c)
OBJS = $(patsubst src/%.c,build/%.o,$(wildcard src/*.c))
Fix pattern¶
- If you need expensive computation, move it into a target (a manifest), not
$(shell ...).
Proof hook¶
Capture baseline metrics:
mkdir -p build
make trace-count | tee build/trace.before
(/usr/bin/time -p make -n all >/dev/null) 2>&1 | tee build/time.before
After your change, capture again and diff:
make trace-count | tee build/trace.after
(/usr/bin/time -p make -n all >/dev/null) 2>&1 | tee build/time.after
diff -u build/trace.before build/trace.after || true
diff -u build/time.before build/time.after || true
Treat trace-count as a heuristic (a signal), not a gate.
8) Core 5 — Failure Modes, Migration Rubric, Canon, Anti-Patterns¶
Definition¶
This core is where you stop pretending every problem is solvable “with better Make”. It also gives you a pasteable canon of patterns you can deploy without improvisation.
Semantics¶
-
Make is excellent when:
- outputs are files,
- dependencies are expressible as edges,
- and concurrency hazards are controlled.
-
Make becomes the wrong core tool when:
-
you need remote caching/sandboxing as a first-class guarantee,
- non-file semantics dominate,
- platform/config matrix dominates the Makefiles.
Migration Rubric: When to Stay vs. Hybrid vs. Migrate¶
Use this concrete decision framework:
| Question | Stay with Make | Consider Hybrid | Migrate Away |
|---|---|---|---|
| Primary outputs are files with clear deps? | Yes | Maybe | No |
| Concurrency hazards modelable with edges? | Yes | Yes | No |
| Need remote caching/sandboxing first-class? | No | Yes (wrap tools like Bazel/Ninja) | Yes |
| Configuration matrix dominates Makefiles? | No | Maybe | Yes |
| Non-file tasks (deploy, DB migrations) central? | No | Yes | Yes |
Safe hybrid examples: - Keep Make as top-level orchestrator with public API and proofs. - Delegate subsystems via stamped targets:
rust-lib: rust.stamp
+cargo build --release
touch rust.stamp
app: rust-lib $(OBJS)
$(CC) ... rust-lib/target/release/lib.a
This ensures deliberate evolution while preserving verification (selftests remain valid).
Failure signatures (canonical)¶
- Non-convergence: second run still does work.
- Serial/parallel mismatch:
-j1output differs from-jN. - Heisenbugs: races disappear under
-j1or “after clean”. - Entropy injection: metadata becomes part of artifact identity unintentionally.
- Recursion collapse: sub-build ignores jobserver budget.
Minimal repro¶
Repro: shared append race (two writers, one file)
Under -j, the interleavings are nondeterministic; under enough stress you’ll see corruption or order variance.
Fix pattern¶
- One writer per output. If you need aggregation, model it as a separate target that reads inputs and atomically publishes a single output.
Proof hook¶
- Prove the bug exists:
rm -f build/log.txt; make -j8 all; sha256sum build/log.txt
rm -f build/log.txt; make -j8 all; sha256sum build/log.txt # same
Pasteable canon (10 patterns, with invariants)¶
These are intentionally boring. Each exists to eliminate a known class of failures.
- Atomic publish + delete on error
- Directory scaffold (order-only)
- Depfiles (
.d) + inclusion
- Grouped multi-output with version fallback (≥4.3)
ifeq ($(filter 4.3% 4.4% 5.%,$(MAKE_VERSION)),)
gen.h: gen.py ; $(PYTHON) $<
gen.c: gen.py ; $(PYTHON) $<
else
gen.h gen.c &: gen.py ; $(PYTHON) $<
endif
- Toolchain identity stamp (order-only)
stamps/cc.txt: | stamps/
tmp=$@.tmp.$$; $(CC) --version > $$tmp; \
if ! cmp -s $$tmp $@ 2>/dev/null; then mv -f $$tmp $@; else rm -f $$tmp; fi
app: main.o | stamps/cc.txt
$(CC) -o $@ main.o
-
Docker context hash stamp (Only works if your context file list is explicit and stable.)
-
Non-recursive Rust aggregation (Prefer a single DAG; treat Cargo as a tool invocation.)
-
CI up-to-date check (
-qexit semantics)
- Environment pin + env stamp (convergent vs attest)
Convergent stamp (safe to be in all’s closure):
export LC_ALL := C
stamps/env.txt: | stamps/
tmp=$@.tmp.$$; printf 'LC_ALL=%s\n' "$$LC_ALL" > $$tmp; \
if ! cmp -s $$tmp $@ 2>/dev/null; then mv -f $$tmp $@; else rm -f $$tmp; fi
app: main.o | stamps/env.txt
Attestation stamp (uses FORCE; keep it out of all):
- Normalized archive
dist.tar.gz: all
# Portable, reproducible archive (stable order + fixed mtimes), matching capstone.
$(PYTHON) scripts/mkdist.py $@ build/
Anti-pattern gallery (memorize the smell)¶
.PHONYon real file targets → perpetual rebuild loops.- “Always-run stamp” (
stamp: ; date > $@) → non-convergence by design. - Temp collisions (
tmp=build/tmp) under parallelism → intermittent corruption. - Parse-time discovery via
$(shell find / ...)→ nondeterminism + slowness. - Recursive make via
make -C(not$(MAKE)) → jobserver collapse.
9) Capstone Sidebar¶
Use capstone to validate, not to learn the basics.
Runbook (repo root)¶
make PROGRAM=reproducible-research/deep-dive-make capstone-portability-audit
make PROGRAM=reproducible-research/deep-dive-make test
make PROGRAM=reproducible-research/deep-dive-make capstone-verify-report
make PROGRAM=reproducible-research/deep-dive-make capstone-hardened
Where to look (file map)¶
- Contract gates + probes:
capstone/mk/contract.mk - Atomic helpers and safe shell patterns:
capstone/mk/macros.mk - Object rules + depfiles:
capstone/mk/objects.mk - Convergent stamps/manifests:
capstone/mk/stamps.mk - Proof harness (convergence/equivalence/negative/perf):
capstone/tests/run.sh - Race repro pack:
capstone/repro/*.mk - Codegen stressors:
capstone/scripts/*
10) Exercises¶
Each exercise is Task → Expected → Forensics → Fix.
Exercise 1 — Add a hard GNU Make floor¶
- Task: enforce GNU Make ≥ 4.3 at parse-time.
- Expected: unsupported Make fails immediately with a clear error.
- Forensics:
make -p | grep '^MAKE_VERSION'. - Fix: use prefix filtering (
4.% 5.%), not naive string comparisons.
Exercise 2 — Prove jobserver propagation across recursion¶
- Task: create
thirdparty/Makefilewith a slow target and call it from the root. - Expected:
make -j4 thirdpartyrespects the same job budget. - Forensics:
make --trace -j4 thirdparty | grep -n '\-C thirdparty'. - Fix: replace
makewith$(MAKE); add+to preserve dry-run semantics.
Exercise 3 — Hermeticity-by-modeling: tool and env stamps¶
- Task: implement
stamps/tool/cc.txtandstamps/env.txt. - Expected: stamps update when inputs drift;
apprebuild behavior matches your chosen policy (order-only vs real prereq). - Forensics:
make --trace appand inspect which prereq triggered. - Fix: make stamps convergent; avoid writing timestamps unless explicitly intended.
Exercise 4 — Attestation must not poison equivalence artifacts¶
- Task: add
attesttarget that writesbuild/attest.txt. - Expected: running
make attestdoes not change the hash of build outputs. - Forensics:
sha256sum appbefore/after. - Fix: do not include
attestinallprerequisites; keep it post-build.
Exercise 5 — Remove one avoidable parse-time cost¶
- Task: introduce a deliberately repeated expansion; measure; then cache it.
- Expected: trace-count and timed
make -n allshow reduced parse/decision cost; build behavior unchanged. - Forensics: diff your
build/trace.*andbuild/time.*before/after; confirmmake -q all. - Fix: compute discovery lists once; push expensive work into targets.
Exercise 6 — Migration drill (hybrid boundary)¶
- Task: wrap an external build tool behind a single Make target with an explicit stamp.
- Expected: Make remains the orchestrator; proof harness still validates declared artifacts.
- Forensics: demonstrate
selftest(or your local equivalent) still proves equivalence. - Fix: treat external system as a black box; don’t dissolve your artifact boundary.
11) Closing Criteria¶
You are done only when all proofs pass:
- Contract proof: unsupported GNU Make fails fast; supported versions warn+fallback correctly.
- Recursion proof:
$(MAKE)is used everywhere recursion exists; jobserver propagation is observable;MAKELEVELis bounded. - Hermeticity proof: tool/env/flags are modeled via convergent stamps/manifests; you can explain every rebuild with
--trace. - Attestation proof:
attestproduces metadata without changing artifact hashes (unless explicitly designed to). - Performance proof: you can demonstrate at least one removed parse/decision-time cost using trace-count and timing.
- Failure-mode proof: you can reproduce (and then eliminate) at least one nondeterminism bug (shared append, temp collision, or missing edge).
- Decision proof: if rubric says “hybrid”, you can keep Make’s public API stable while delegating internals safely.
Directory glossary¶
Use Glossary when you want the recurring language in this module kept stable while you move between lessons, exercises, and capstone checkpoints.