a73x

c694896f

build: an empty diff is not an invitation to mutate the tree

a73x   2026-08-22 16:18

Commit message
build: an empty diff is not an invitation to mutate the tree

Gremlins reads a diff with no entries as "every line changed", so the
check that runs on each push became a 22-minute whole-tree sweep the
moment main was pushed and origin/main caught up with HEAD. That sweep
reaches the one mutant that SIGKILLs its own process group, so the
build did not fail so much as vanish.

Skip when no Go file differs from the base, and put the run in its own
session so a mutant cannot signal the build that started it.

Makefile
Old New
@@ -201,8 +201,20 @@ mutate-diff: gremlins-tool
201 # first: the tree has equivalent mutants that CANNOT be killed (internal/ 201 # first: the tree has equivalent mutants that CANNOT be killed (internal/
202 # cloudinit's `i+1 < len(root.Content)` is the same loop for every even-length 202 # cloudinit's `i+1 < len(root.Content)` is the same loop for every even-length
203 # YAML mapping either way), so a 100% floor would fail honest work. 203 # YAML mapping either way), so a 100% floor would fail honest work.
204 # The empty-diff guard is not an optimisation. Gremlins treats an EMPTY parsed
205 # diff as "everything changed" (internal/diff: IsChanged returns true when the
206 # diff has no entries), so -D against a base that equals HEAD silently becomes a
207 # whole-tree run — 22 minutes, and it reaches internal/agent/cloudhv, whose
208 # `if pid != 0` mutant turns into kill(0) and SIGKILLs this make process. Which
209 # is exactly what a push to main does: it moves origin/main to HEAD, and the
210 # NEXT ci run has nothing to diff.
211 #
212 # setsid for the same reason: a mutant that signals its own process group must
213 # not be able to reach the build that spawned it.
204 mutate-report: gremlins-tool 214 mutate-report: gremlins-tool
205 -@$(GREMLINS) unleash --workers $(GREMLINS_WORKERS) \ 215 @git diff --quiet --merge-base $(MUTATE_BASE) -- '*.go' 2>/dev/null && \
216 { echo "mutate-report: no Go changes against $(MUTATE_BASE) — skipped"; exit 0; } || \
217 setsid $(GREMLINS) unleash --workers $(GREMLINS_WORKERS) \
206 --timeout-coefficient $(GREMLINS_TIMEOUT) \ 218 --timeout-coefficient $(GREMLINS_TIMEOUT) \
207 -D $(MUTATE_BASE) . 2>&1 | tail -6 || true 219 -D $(MUTATE_BASE) . 2>&1 | tail -6 || true
208 220
RETRO.md
Old New
@@ -9,3 +9,7 @@ One line per push to `main`: what slowed the work down. Enforced by
9 filesystem, not the tool, was producing the numbers — ext4 reported 207 9 filesystem, not the tool, was producing the numbers — ext4 reported 207
10 timed-out mutants and a flattering 100% efficacy where tmpfs reported 199 10 timed-out mutants and a flattering 100% efficacy where tmpfs reported 199
11 killed and an honest 93%. 11 killed and an honest 93%.
12 - The diff-scoped mutation check was wired without asking what an empty diff
13 means. Pushing main answered it: origin/main became HEAD, gremlins read a
14 diff with no entries as "everything changed", and the whole-tree run it
15 started was killed by the one mutant that signals its own process group.