Every gate is cheap to run, and each is run on its own: there is no aggregate runner. The lists below are the statement of record; no count of them is written in this file, because a number beside a list is a second claim about one population with nothing checking the two against each other, which this file has already recorded happening twice.
| gate | reads | needs |
|---|---|---|
test-inventory.sh |
the three lists that claim to cover src/, every file under test/, the DEFER ledger and the XXX table |
sh, grep, git |
test-citations.sh |
every file:line in a table under doc/ or doc/history/ resolves, and the named symbol is on the line |
sh, grep |
test-history.sh |
every changelog row's commit resolves with a matching subject, and no two rows share a version | git with full history |
test-provenance.sh |
every file under src/ has an origin row, and a carried file is re-checked with cmp |
sh, git |
test-absence.sh |
every "X() is not carried" and "->method is not written" claim resolves against src/ |
sh, grep |
test-shim.sh |
the shim parses against test/stub in both knob positions, with a control that must fail |
a C compiler |
test-syntax.sh |
the files it names compile under clang and gcc at the kernel of record, warnings are failures, with a control | that kernel's tree, clang and gcc |
test-checkpatch.sh |
the style deviation set against doc/checkpatch-baseline.txt |
checkpatch.pl from the kernel of record, via CHECKPATCH or KDIR |
test-vectors-contract.sh |
the exit status, output wording and constant spelling of the two vector files a consumer compiles | a C compiler |
test-posix.sh |
the gates declaring #!/bin/sh parse under dash and busybox ash |
dash and busybox |
test-doc-prose.sh |
vale over every tracked .md; any finding is a failure |
vale |
test-fixtures.sh |
every fixture mounted on a guest, every file compared to its manifest | a guest, the fixture images, KDIR matching the guest's kernel |
test-enospc.sh |
a volume filled as root or as a user, what it kept and what each writer was told | the same |
Exit 2 from any of them means the instrument could not run, which is neither pass nor fail and is never recorded as either.
The scripts below need the guest fleet and are not gates. Each builds
the module itself, exits 2 without the fleet, and produces a reading
rather than a verdict; the readings are in
doc/history/verification-record.md.
| script | does |
|---|---|
fuzz-mount.sh |
puts mutated images through the mount path and, with H2_FUZZ_WRITE=1, the write path |
f4-roundtrip.sh |
writes a tree here and checks it on DragonFly, then the other way |
cut-flush.sh |
cuts DragonFly off mid-write and mounts the result here |
crash-matrix.sh |
runs the 0.6 crash matrix against the FreeBSD port |
root-boot.sh |
boots a kernel whose root filesystem is a HAMMER2 volume, with the mapping and exec checks that let it be one |
pfs-domains.sh |
creates PFS roots here, mounts each by label on both sides, and has DragonFly check what was written in each |
million-tree.sh |
writes a million-file tree here and has both sides count it |
throughput.sh |
times one large file against ext4 and reads its allocation order from the image beside DragonFly's |
nix-closure.sh |
copies a real Nix closure in through the port and reads it cold beside squashfs and erofs |
bulkfree.sh |
writes a set, removes it, and runs the bulkfree scan that frees it |
hpanic-contain.sh |
reads what a device in error keeps off the media |
dfly-enospc.sh |
fills a volume to capacity on the DragonFly guest under an allocator that refuses on a count, the reproducer behind the staged unmount patch |
test-absence.sh resolves a claim rather than a citation. Where a document
says a named function is not carried, it asks src/ whether that function
is defined and fails when it is. The vocabulary is the origin table's:
carried means imported substantially unchanged, and a function this port
rewrote is rewritten, so a symbol that is present here has not been "not
carried" whatever else is true of it. Its first run found one, in
hammer2_inode.c, where hammer2_igetv() was called uncarried after this
port rewrote it on iget5_locked(). It reads only the claims that name a
symbol, which is a fraction of the class it belongs to, and it says so on
every run rather than leaving the rest to inherit its credibility.
It reads a second claim shape since 2026-09-04: ->method is not written,
resolved against the operations tables by asking whether that member is
initialized at the start of a line under src/. That shape was added
because the first one could not see the defect that kept recurring. Three
documents described ->iterate_shared as unwritten after it was, and the
README's opening paragraph said the port does not mount anything for four
days after it began mounting. Its first run found ->reconfigure
described as unwritten while it is wired into hammer2_fs_context_ops
and deliberately returns -EROFS, which is a stronger statement than the
prose was making.
Only the arrow form is read, a bare method name not being distinguishable
from ordinary prose, and only the present tense. A claim written in the
past with the commit it was true at is a dated observation and cannot go
stale, which is why doc/history/verification-record.md records the readdir floor as "was
not written at 1f025fe". The gate carries a --selftest driving six
directions, including that one, on a fixture tree rather than on this
repository's own prose.
test-shim.sh compiles hammer2_os.h and hammer2_compat.h against the
stubs in test/stub, in both positions of the HAMMER2_INVARIANTS knob,
plus a negative control: the header is broken on a copy and the compile
must fail. Without that control a gate whose healthy signature is silence
cannot be told from a gate that never opened the file.
make was first run on 2026-09-02. It put thirteen objects and their
.cmd files beside the sources, and test-provenance.sh and
test-inventory.sh went red on the spot: the first asked for an origin row
for each of thirty files kbuild had just written, the second read XXX out
of the strings inside hammer2.o and asked the status table for a row.
Neither had a bug that could pass something wrong. Both enumerated src/
and had never seen anything there that was not source.
test-checkpatch.sh and test-citations.sh had the same shape without
having tripped. The one that bites latest is the *.c glob: kbuild writes
hammer2.mod.c, which no build has reached, because modpost stops first.
All four now exclude kbuild's output, and the patterns match .gitignore's.
The permanent guard is not a new gate but an ordering: the pre-push hook
builds the module before it runs any gate, so every gate runs against the
tree a developer actually has. Until 2026-09-03 this paragraph also said that step asserts
the undefined set is exactly the four named in doc/README.status.md.
Nothing asserted that. The step read modinfo and counted warnings, and a
fifth undefined reference would have been a failed link with no list, which
is a red run but not the one described here.
What it does assert is in script/build-check.sh, which is not a gate and
is named build-check rather than test- for that reason: the build fails,
or the build is not warning-clean, or the build reports success and there is
no hammer2.ko. It lives in a script because it has two callers now, the
kernel of record and the floor, and a check copied into a second caller can
rot in one copy while the other stays right. Its three failing directions
were driven on 2026-09-02 and 2026-09-03, by giving hammer2_io.c a call to
an undefined function, then an unused static, and by pointing it at a
directory holding no kernel, which is COULD-NOT-RUN and not a pass.
Its warning pattern requires file:line:column, because a bare warning:
also matches kbuild's banner about the runner's compiler differing from the
kernel's, which is a fact about the machine. That failed the step for two
runs while the build was clean. The pattern is therefore checked against a
line built to match before it is trusted on a log that should have none,
since no warnings and a pattern that stopped matching print the same number.
The floor and the kernel of record are the same release, 7.3, built on
the maintainer's machine, so the syntax gate and build-check.sh against
that tree, both run by the pre-push hook on every push, are the only
builds there are. Hosted CI builds nothing: the runner has neither that
tree nor headers at the floor, and fetching and building a kernel there
only repeated what the push had done. From 2026-09-03 to 2026-09-05 a second CI job fetched a 6.15
tarball, built the kernel and linked the module against it, because the
floor was 6.15 and nothing had ever compiled there. It found two spellings
the floor lacked, then a type rename, then a codec defconfig leaves out,
then a config edit olddefconfig silently undid, then the ->write_begin
signature, and the floor moved to 7.3 with the job deleted;
README.porting.md has the ruling. What that job taught survives it:
build-check.sh takes a KDIR, so a build against any tree is one
command, and a build that has never been run against the tree the
#error names is an assertion and not a constraint. The constraint is
measured from the other side too: KDIR pointed at a mainline 7.2
tree with H2_KERNEL_REF=7.2 fails 44 of 46 checks, and the errors
under the #error are the two facilities the floor exists for;
README.status.md quotes them.
Between 2026-08-29 and 2026-09-03 this repository sent twenty-six failed
CI runs, counted by asking the API which step failed in each rather than
by remembering: twenty-one at a module build, three at the repository
gates, and two at the floor job's own assertion that its kernel tree
carries a symbol table. Two of the twenty-six were the gate's fault, a
bare warning: matching kbuild's compiler banner; every other one was a
real defect, in the tree or in the workflow being written at the time. So
the gates were right and the volume was a working habit rather than a
defect rate: CI was being used as a compiler, one push per question, and
each answer arrived as a failure notification to the maintainer.
test-doc-prose.sh runs vale over every tracked .md file with the
styles in styles/, which are house YAML rather than a downloaded package
so the gate needs no network and no vale sync. It arrived on 2026-08-29
with doc/research/, which had been governed in Saxum since 2026-08-25
and was moved here on the rule that a component owns its own development.
Two things about it were wrong until 2026-09-02 and are worth recording,
because both are the shape where a gate prints and still passes. Vale's own
exit status is nonzero for errors only, and every rule in styles/Hammer2
is a warning, so the gate printed twelve findings and exited 0 on every run
it ever made. It now counts the findings itself and fails on any of them;
the twelve, eleven British spellings and one wordy phrase, were fixed in
the same change. And CI never installed vale, so the gate reported
COULD-NOT-RUN on every push, which is the same defect the move was meant to
close, one layer out. A third was wrong until 2026-09-04: the population
was find doc, which is every document except the four a reader meets
first, so README.md, CONTRIBUTING.md, CHANGELOG.md and the pull
request template were ungoverned. That is the same defect the gate's own
header records about doc/research/, applied to the directory that
prompted it rather than to the tree. The README's opening paragraph said
this port does not mount anything for four days after it began mounting,
and when the population widened those four files held seven British
spellings. The population is now git ls-files, so a new document is
governed the day it is committed, and the three root files are asserted
by name rather than counted: a population that narrowed back to doc/
would still be non-empty and would still pass, which is how this gate
read past the README. CI now installs vale pinned by version and sha256,
for the reason checkpatch is pinned: a different checker reports a
different finding set on unchanged prose. The version of record is 3.18.0.
The gate asserts a non-empty population before it reads anything, so a
doc/ that has moved fails rather than passing on an empty sweep, and it
carries no negative control of its own because the failing direction was
driven by hand: a one-line document containing a British spelling turns
the run red and its removal turns it green again.
test-provenance.sh reads doc/provenance.csv and asks three things:
that no file under src/ lacks a row, that no row names a file that is
gone, and that every row claiming a byte-for-byte carry still IS one.
Only the third asks a question this repository cannot answer alone, and it
is the reason the gate exists: an origin, commit and license claim is the
first thing an upstream reviewer checks and the last thing anyone can
reconstruct afterwards. So it is re-run with cmp against the origin
clone rather than read. Where no clone is on the machine, nothing was
verified that this tree could not verify about itself, and the gate exits
2 rather than passing on a table that only agrees with itself; CI clones
the origin at the commit the CSV names so that check runs on every push.
What it cannot do is in its own header: derived and ours rows have no
mechanical test, so they are counted in the summary rather than checked.
test-syntax.sh compiles hammer2.h and hammer2_io.c against the real
kernel headers with two compilers, clang and gcc, under a W=1-class
warning set. Two compilers because they disagree about what is worth
saying, and a single one is a single opinion: both independently reported
the LIST_HEAD and RB_ROOT redefinitions, which is what made those
credible rather than stylistic. A warning in a file under src/ fails the
gate; one in a kernel header does not, since we do not own those and
cannot fix them. gcc is optional and the gate says so when it is absent.
Its header line names WHICH resolution it took - KDIR,
/lib/modules/$(uname -r)/build, or the nix-store fallback - because a
fallback that has never fired is indistinguishable from one that works.
That is not hypothetical here: IO_MODEL.md described the nix branch as
the source of the kernel of record while the /lib/modules path was
present on every run, so the document and the script agreed in wording and
disagreed in behavior, and nothing could notice. Point KDIR at a path that
does not exist to exercise the fallback: it then resolves nothing and returns
COULD-NOT-RUN naming itself.
It also refuses a kernel that is not the one of record: this tree compiles
against the latest Linux, the pin is KERNEL_REF in the script, and a
tree of any other version is COULD-NOT-RUN. H2_KERNEL_REF checks another
version deliberately, which is the only way that reads as a pass.
It carries two more controls: a wrong folio call that the
same headers must refuse, and the 64KB ceiling guard, which must fire when
the ceiling is shrunk. Set KDIR to test against a tree other than the
running kernel's.
test-checkpatch.sh is the odd one: it does not ask for silence, it asks
that the recorded deviation set has not grown. It identifies its checker by
sha256 against the baseline's second line, and prints where every value
came from, because an ASSERTED version and a DERIVED one used to render
identically: pointed at the v6.15 checker with CHECKPATCH_REF=v7.2, it
reported a real style regression, the assertion having laundered a wrong
checker into a verdict about this code. A content mismatch is
COULD-NOT-RUN now whatever names the checker carries. See
README.kernel-style.md for why this tree is BSD
style on purpose and what that means for mainline. Both of its sorts are
LC_ALL=C, because the baseline is compared byte for byte and glibc
collation differs between machines; the first CI run failed with every
count identical and four lines in a different order.
None of the compile gates runs anything. -fsyntax-only compiles nothing
and links nothing, which is the honest limit of what can be checked before
a module builds.
The repository gates check the documentation against the tree rather than
the tree against a compiler. test-inventory.sh reads the three
hand-maintained lists that claim to cover src/sys/fs/hammer2/, reports a
file missing from any of them, and compares the origin table's line count
against the file it names. test-citations.sh reads the file:line citations that sit in a
doc/ table row against the line each names, comparing against the source
rather than a stored baseline, and grades each pass by how specific its
anchor is, so a row anchored on a common token is reported as weak rather
than counted with the strong ones. It checks table rows only, and prints
how many citation-shaped tokens it left alone: 33 on 2026-09-05, of which
31 are in doc/research/ and name line numbers in DragonFly's own tree,
which this repository cannot resolve. That is why the exclusion exists,
and the count is printed rather than assumed so a prose citation into
src/ cannot hide in it. The one that is such a citation,
hammer2_inode.c:1664 in doc/README.status.md, was read by hand on the
same day and lands on the size comparison the sentence describes. This
paragraph's own example is the thirty-third.
test-history.sh checks that every roadmap row's commit hash resolves
with a matching subject, and names any deliverable commit that has no row.
test-inventory.sh also reads the DEFER ledger in
doc/README.status.md against src/ in both directions: a marker with no
row, and a row whose marker the source no longer holds. The second is the
one nothing else would catch, since a deleted marker leaves a row reading
as outstanding work forever. The match is on the marker text verbatim, so
rewording a trigger in one place and not the other is a failure rather
than a drift. Both directions were driven on 2026-08-26 by making each
break in turn.
test-inventory.sh has a second population, test/, where every file
must either be named by a gate or be listed as staged below. It also
checks two DIFFERENT claims about the gates themselves: that no document
states a wrong COUNT of them, and that the three documents printing
runnable command lists NAME every one. Those are not the same check -
The agent instructions file, since untracked, said "eight" correctly on
2026-08-26 while listing seven, so
the count passed and the list a future reader would run was short by the
newest gate.
What none of them can check is whether a row's CLAIM is true; that takes a
person reading the artifact the row names.
test-syntax.sh --selftest and test-checkpatch.sh --selftest check the
two prints that separate a loosened run from a real one: the override
warning and the checker's sha256 provenance. Both prints were added on
2026-08-26 to fix the class where output nobody reads is trusted, and
neither was read by anything, which is that same defect arriving inside its
own repair. The syntax selftest failed on its first run because the warning
WRAPS and the matcher read one line at a time - a rule about matching
wrapped prose not firing while writing a matcher for wrapped prose. CI runs both, and derives which gates have a --selftest rather than
naming them. They re-invoke their own gate with bash, never sh: the
first version used sh "$0", which works on a machine whose /bin/sh is
bash and is a syntax error under dash, so it passed here and failed on the
runner. That is the class of defect a local run cannot reach, and it is
what CI is for.
The syntax selftest's unoverridden direction is exercised only where the kernel of record is present. Elsewhere it prints a note saying it was not exercised, rather than failing: on a hosted runner that tree is absent, and failing there would turn an environment difference into a red gate.
The syntax selftest's third check is a designed guard replacing an
accidental one. The gate reads VERSION/PATCHLEVEL from a build tree's
own Makefile, so linux-api-headers cannot satisfy it - and that
immunity was luck of construction, not intent, until the check existed. A
guard nobody designed is a guard nobody maintains. The specimen is a
directory holding nothing but an include/linux/version.h claiming 7.2,
which must be COULD-NOT-RUN. Falsified with the plausible improvement a
later maintainer makes, a version.h fallback when the Makefile is
missing: the gate then accepts the fake and prints 7 check(s), 5 failed against the kernel of record (7.2), charging five failures to this code
on behalf of a kernel that does not exist.
Every gate that uses a toolchain names the one it used, and every gate
that resolves a tree names how it resolved it. The reason is a shape worth
recognizing: where the DEFAULT invocation and a deliberate one answer
different questions, the unattended run and the careful run disagree and
only the careful one is ever right, while both print the same summary. It
was live on 2026-08-26 in the syntax gate, which needed KDIR typed to
reach the kernel of record, and in the style gate, which read the host's
build tree while the record's own checkpatch.pl sat in the store. It was
live in test-shim.sh too, though not in the direction that sentence
guessed. It said a reviewer reaching for clang would get a different
opinion; what happened on 2026-08-26 is that two versions of the same
compiler disagreed. GCC 13 on the runner accepted a struct file the
stub tree never declared and GCC 16 here warned about it, so 346dac6
was green in CI and red on the workstation. The gate names its compiler
in its header line and does not require one, which is the right trade for
a gate whose whole point is needing nothing but cc: two opinions are
worth more than one pinned opinion, as long as a disagreement is read as
a finding rather than a flake. It is still true of the vectors
contract.
That sentence opened with a count instead of naming them, and
script/test-inventory.sh failed it: a number word immediately before
"gates" is compared against how many gates exist, so a PARTIAL count in
that shape is a finding. The gate was right, and naming them costs
nothing. The first attempt to document this failed the same check again,
because writing the offending phrase in an explanation is still writing
it - the check reads the file, not the intent.
On a hosted runner test-syntax.sh is the only gate that declines, because
the kernel of record is the latest release and ubuntu-latest ships
headers years behind it. That is recorded as a skip and never as a pass.
Everything else runs there, including the vectors contract's behavioral
half, which had been declining for want of an xxHash to link until
libxxhash-dev was added to the runner on 2026-08-26.
test-posix.sh parses the gates declaring #!/bin/sh with dash and
busybox ash. It exists because every gate here is normally run by bash, so
a bash-only construct in such a script runs forever and breaks the day
something honors the shebang - which happened on 2026-08-26, when both
selftests re-invoked their gate with sh "$0" and failed on a runner
whose /bin/sh is dash.
It MEASURES its own reach on every run and prints it as observed, rather than asserting a table. A gate stating its own coverage is a claim nothing checks, sitting in the one place a reader uses to decide whether a clean run means anything. The first two versions of this gate carried such a table, and both were wrong: one named a single construct, the next three, where there are four. An under-claim is as false as an over-claim and is re-checked less often.
What it asserts instead are two properties of a working checker, which hold whatever the reach turns out to be: each shell must reject at least one probe, or it is inert here and a clean result means nothing; and a plain POSIX script must be accepted, or the instrument refuses everything and a clean result is unreachable rather than earned. Falsified both ways - making every probe inert, and making the plain script unparseable, each fail naming which property broke. With no shell realized the gate exits 2.
Since 2026-09-05 it also reads the remote blocks the fleet scripts hand to
ssh, which are shell that no gate had ever parsed. Those blocks are passed
as one single-quoted string, so a single quote anywhere inside a block ENDS
that string: everything after it is expanded by the workstation instead of
the guest. A shell parse cannot see that, because the text left behind is
still valid shell, so the gate checks the property the quoting actually
rests on and counts the single quotes in each block, which must be zero. It
parses the blocks as well, and asserts the population, since a pattern that
stopped matching would check nothing and still print a clean count. The
check was written after an awk '{print $3}' added to test-enospc.sh aborted
a run on the workstation with $3: unbound variable before the guest was
reached, and it reads 2 against that block and 0 against the repaired one.
Observed on 2026-08-26: dash rejects process substitution, arrays, the
function keyword and here-strings; busybox ash rejects arrays and
here-strings; both accept [[ ]], declare, local, +=, arithmetic,
ANSI-C quoting and brace expansion as ordinary words. So a clean run means
"no bash SYNTAX" and never "no bashisms". The hosted runner reproduced
those figures exactly, on a different distribution.
The shells are found in the nix store, not on PATH. An earlier version of
this section said no POSIX shell existed on the development machine, on
the strength of command -v, while both were already installed under
/nix/store. On a machine whose software lives in a store, PATH answers
for the current session and not for the machine.
CI was being used as a compiler: one push per question, each answer arriving as a failure notification. Twenty-six such runs between 2026-08-29 and 2026-09-03, and two more on 2026-09-04 when the checkpatch pin moved and the gate selftests were not run locally, which CI runs as a step of its own.
script/pre-push-check.sh runs every script/test-*.sh and every gate
selftest, the selftests enumerated by implementation rather than by name
the way CI enumerates them. Install it:
$ ln -sf ../../script/pre-push-check.sh .git/hooks/pre-push
It is not named test-*.sh and does not live among the gates as an equal:
the thirteen are run individually on purpose, and one exit status for
thirteen questions is the thing this repository does not want. H2_SKIP_PREPUSH=1
overrides it for a push that is deliberately ahead of a green tree.
It runs what CI runs, and CI's result still has to be read on the forge
after the push: three pushes on 2026-09-04 were red there on one
undefined symbol while every local gate was green, and each got its
changelog row. gh run list after a push is part of the push.
It fetches the checker the baseline records and caches it under
$XDG_CACHE_HOME/linux_hammer2, keyed by the sha256 the baseline names.
Without that the style gate finds whatever checker the machine has, cannot
attribute a moved deviation set to this code, and reports COULD-NOT-RUN,
which this check would report as a warning: a real style regression would
then pass a push. Both directions were driven before it was committed. On
a clean tree it exits 0; with one category count altered in the baseline
it names the moved line and exits 1.
There is a Linux-native HAMMER2 writer on this machine and there has been
since 2026-08-25: Kusumi's makefs port, packaged by the distribution and
built in the store. doc/research/HAMMER2_TEST_FIXTURE_PLAN.md names it
and every other writer in the fleet. Nothing in this repository has to be
written to produce media, and a claim that media is unavailable is a claim
about a search, not about the machine.
makefs -t hammer2 -o Label=TEST,MountLabel=TEST <image> <tree>
Both options or it fails: Label= creates the PFS and MountLabel= picks
where the tree lands, and with only the first the tool creates the PFS and
then tries to mount the default DATA.
Two costs measured rather than assumed. The image is fully allocated and
not sparse, so it occupies its full size on the filesystem holding it, and
the default size is 8 GiB. -s 1g is refused, and the tool reports
trying default image size 8.00GB, which does not agree with the
toolchain document's statement that images size in 1 GiB chunks and the
packaged test takes one. Whichever is right, an 8 GiB write per fixture is
what this machine does today, so fixtures belong on real storage and want
caching rather than regenerating.
The mount names the PFS, and a mount without one asks for DATA:
mount -t hammer2 -o ro /dev/loop0@TEST /mnt/h2
An image made by makefs is a Linux tool's output. The milestone's claim
is about media DragonFly wrote, which is the dragonflybsd642 guest in the
fleet, and the two are different measurements.
f1.img holds five paths and its largest file is 16 bytes, which is under
HAMMER2_EMBEDDED_BYTES and so lives in the inode. It proves the
directory operations and nothing about the on-media read path, because
every file in it takes the embedded branch.
f2.img exists for that reason and is built on the boundaries the read
completion branches on: 511 bytes, 512, 4096, 65536 and 200000. The pairs
matter more than the sizes. 511 and 512 straddle the embedded limit, so
one reaches the inode and the next reaches a data block, and 200000 is the
only one where a folio can begin partway through a block, which is the
arithmetic upstream has no counterpart for.
makefs -t hammer2 -o Label=TEST2,MountLabel=TEST2 f2.img tree2
Compare with md5sum against the tree the image was made from rather than
by reading the files, and read at an unaligned offset inside the largest
with dd skip=, which no whole-file compare exercises.
f3.img and f4.img are the same tree written with
CompressionType=lz4 and CompressionType=zlib. That option is why the
compressed floors stopped being unmeasurable: they had been recorded as
needing a fixture that did not exist, when the fixture was one flag away.
Before writing either decoder, run the floors against these images. The
compressible file must fail with EIO and name its method in dmesg
while the incompressible one reads, which proves in one run both that the
floor refuses rather than corrupts and that the image holds what its name
claims. A decoder written first would have had neither guarantee.
tree3 is chosen so that one file compresses and one cannot, since a
compressor that falls back on incompressible data writes a raw block
inside a compressed volume, and that mixed case is the one a single
compressible file would miss.
makefs -t hammer2 -o Label=TEST3,MountLabel=TEST3,CompressionType=lz4 \
f3.img tree3
Check these two with the page cache dropped as well as cold, and scan kmemleak twice: both decompression paths allocate per folio and the ZLIB one also allocates an inflate workspace, so a leak here is per read rather than per mount.
script/test-fixtures.sh is every read-path measurement in this document
run without a person typing them. It builds the module, starts the guest
if it is not already running, attaches each image in H2_FIXTURE_DIR
whose manifest is committed under test/fixtures/, mounts it read-only by
the label the manifest names, and compares every file with md5sum -c.
The manifests are here and the images are not, because an image is 2 to 8
GiB and fully allocated while the manifest is the part that constitutes
the claim. f5.manifest holds the checksums DragonFly itself reported
before unmounting, which is the only form that measurement can be kept in:
regenerating them on Linux would compare this module against itself.
Each manifest also records the on-media block count for every file, which
i_blocks carries, and the gate compares those too. A # link target relpath row records a symlink, which md5sum follows and so never reads;
the gate compares readlink against it. That is an assertion
about the fixture rather than about the code: a set of matching checksums
cannot tell you that an image still holds compressed blocks, so an image
regenerated without compression would pass every checksum while the
compressed path silently stopped being exercised. What the counts do not
distinguish is one compressor from another, f3 at LZ4 and f4 at ZLIB
reporting the same numbers because both compress below the smallest
allocation; the floors run before either decoder existed are what
established which image is which.
The counts corrected a claim in README.status.md on their first run.
d512.bin was described there as the first file on media, and it reports
zero blocks: hammer2_inode.c compares size > HAMMER2_EMBEDDED_BYTES,
so the bound is inclusive and 512 bytes is still embedded. Two fixture
files were testing the same branch while the document said they straddled
it.
It also exercises the ioctl surface on every image that verifies, through
test/hammer2-ioctl-exercise.c, which the gate compiles statically here
and runs on the guest twice: once as root and once under setpriv --reuid=65534. The program includes the driver's own hammer2_ioctl.h
rather than copying the command numbers, so a renumbered command breaks
its build instead of passing against a stale constant, and it prints one
label value line per call which the gate compares literally. Ten
results per image, a hundred over the fixture set.
It covers the read-only subset and says so in the summary line. The fixtures are attached read-only on purpose, so snapshot creation, PFS create and delete, growfs and bulkfree cannot be reached from it and stay hand-verified on a guest; "the ioctls are gated" would otherwise stand for commands nothing here runs. What it does cover is the version, PFS and inode queries, the super-root scan, the volume list, and three refusals: an unknown command under HAMMER2's own type letter, a command belonging to another driver, and a zero-size command, which is reachable only as root because the entry point checks the capability before the size.
Two defects came out of its first two runs, one in the driver and one in
the exerciser. An unrecognized command returned EOPNOTSUPP, which a BSD's
ioctl layer maps to ENOTTY and Linux does not, so userland read
"Operation not supported" where every other driver says "Inappropriate
ioctl for device"; the dispatch's default arm now returns ENOTTY and the
deliberate refusals above it stay EOPNOTSUPP. The exerciser then reported
zero volumes from a call that had succeeded, because nvolumes is the
caller's capacity going in and it had been zeroed. That second one is the
reason the gate checks the two counts for being non-zero rather than only
checking the status: a scan that copies nothing returns success.
It carries a negative control per image rather than only in the selftest.
After a manifest verifies, one hash in it is altered and the same mount is
compared again, which must fail. Without that, an empty sums file, a
silent md5sum and a mount that landed somewhere else all read as a pass.
It leaves the machine as it found it: what it attached is detached, and
the guest is shut down only if the gate started it. It will not start one
unless H2_FIXTURE_START=1 says so, because script/pre-push-check.sh
runs every gate on every push and a gate that boots a 4 GiB domain when it
finds one stopped spends that on every push, on a machine whose memory
somebody else is using. H2_FIXTURE_MODARGS is handed to the guest's
insmod; H2_FIXTURE_MODARGS=io_buf_only=1 reads every fixture
through the block buffer the DIO layer otherwise takes only when the
page cache cannot make a 64 KiB folio, so that path is read by the same
manifests as the page cache path rather than waited for.
Exit 2 is COULD-NOT-RUN and is reported for a missing guest, a stopped one
without that variable, missing images, no KDIR and a guest that does not
answer ssh, because most machines have none of these and CI has none at
all. A gate that passed there would make the whole read path look covered
by CI when nothing ran.
The module has one build-time control of its own, never installed.
make HAMMER2_FOLIO_CONTROL=1 produces a module whose mount-time
folio-size check asks for twice what the kernel offers, so it must
refuse every mount and name both numbers. Build, load on the guest,
run, read dmesg. A second control, HAMMER2_RW_EXPERIMENT, lifted
the read-write mount refusal for measurement from the first read-write
mount to the crash matrix, and retired with the refusal; the runs
doc/history/verification-record.md records under that name were made with it.
One of those is worth its own line. KDIR defaults to the host's own
build tree, so the first pre-push run of this gate built for the host and
reported the guest refusing to load it as a failure. insmod rejects a
module on vermagic, which is knowable before the attempt, so the gate now
compares the module's vermagic with the guest's release and reports
COULD-NOT-RUN naming both. A verdict reached against the wrong kernel is
an artifact of the setup and not a finding about the code.
KDIR=~/kernels/linux-7.3-rc1 H2_FIXTURE_START=1 \
bash script/test-fixtures.sh
Measured on 2026-09-04: eleven images, 43 files, 34 stat rows, 5 statfs
rows, 2 symlinks, one corrupt file refused, one mount refused, 0 failures.
f12 is f1's tree written by DragonFly's kernel, for the listing in
README.status.md.
A # stat mode nlink uid gid inode relpath row and a # statfs size used free inodes-used row carry what DragonFly's own stat and df reported,
and f11 exists to hold hard links, a setuid bit, an owner and a 0750
directory. The ten are f11, makefs
output, makefs at LZ4 and at ZLIB, the boundary tree, media DragonFly
wrote at its LZ4 default, media DragonFly wrote after hammer2 setcomp zlib on the mount root, a device carrying two PFSes of which the gate
mounts ROOT, and two copies of the LZ4 image altered on purpose: f9
with one data byte flipped, whose manifest carries # corrupt random128k.bin and whose other files must still verify, and f10 with
one volume-header bit flipped, whose manifest is # refuse and no file
rows. f8, the installed DragonFly root, has no manifest here: it is
read by Saxum's walker as root and compared against two other readers,
recorded in doc/history/verification-record.md. Mounting both of f7's PFSes at once is
a measurement recorded there too, since a manifest names one label.
The gate starts its guest only when no other domain is running, since
each holds 4 GiB and the host is shared with other sessions' benches;
H2_FIXTURE_SHARE=1 overrides that. It attaches one image at a time,
always as vdb, and releases it before the next. It used to hold every image attached and the guest ran
out of virtio slots at the eighth, which the gate reported as an attach
failure of its own making. For f6 the block counts in the
manifest are DragonFly's own stat output, so that image compares this
reader against the writer rather than against itself.
Everything above is makefs output, which is a Linux tool. For the
milestone's own claim the writer has to be DragonFly:
virsh start dragonflybsd642
virsh attach-disk dragonflybsd642 <image> vdb --targetbus virtio
virsh reboot dragonflybsd642 # no virtio-blk hotplug there
newfs_hammer2 -L DFLY /dev/vbd1
mount -t hammer2 /dev/vbd1@DFLY /mnt/h2w
To ask whether a device callback reaches every PFS mounted on it, wrap
the device in a linear dm target on the Linux guest, mount two PFSes
from /dev/mapper/<name>@<label>, dmsetup suspend it, and fsfreeze -u
each mount: the thaw exits 0 on a frozen superblock and fails with
EINVAL on one the freeze never reached. doc/history/verification-record.md records the
result for f7 with and without the per-mount claim.
Attach without --mode readonly on the DragonFly side: its HAMMER2 opens
the device for writing whatever the mount asks, and a read-only
attachment fails the mount with EINVAL.
For f6 the same, with the disk attached to the shut-off domain under
--config so no reboot is needed, and hammer2 setcomp zlib /mnt/h2w
run before the first file is written, since the setting is inherited by
new inodes and does not rewrite existing ones. Root over ssh works with
the key; the unprivileged user's doas asks for a password.
Two things about that guest cost time. Its root shell is csh, where
2>&1 is a syntax error rather than a redirect, so run anything with
redirection through sh -c or copy a script over. And it does not hotplug
virtio-blk, so a disk attached to the running domain needs a reboot to be
enumerated; virsh reboot keeps the same QEMU process, so a live
attachment survives it where a shutdown would lose it.
Take the checksums on DragonFly before unmounting. They are the ground truth, and taking them afterwards on Linux would compare this module against itself.
Run one guest at a time. Each holds 4 GiB, both together are most of what this machine has spare, and the two halves of the test never overlap: DragonFly writes, then is shut down, then Linux reads.
The Linux guest's kernel is plain 7.3.0-rc1 built on the host from
~/kernels/linux-7.3-rc1, the tree KDIR names, and copied into the
guest by hand; every reading in doc/history/verification-record.md taken on that guest
was taken on it. Its configuration is the tarball's default plus the
debug and instrument options the port's readings depend on:
PROVE_LOCKING with DEBUG_RWSEMS, PROVE_RCU, DEBUG_KMEMLEAK,
BLK_DEV_IO_TRACE, DWARF 5 debug information so addr2line resolves
module offsets, PREEMPT under PREEMPT_DYNAMIC, LOCKDEP_CHAINS_BITS
at 20 since the million-file runs filled the default table of 16 bits
at 172 s and switched the validator off, and SQUASHFS and EROFS_FS
as modules for the closure reference reads. A change to that
configuration changes the guest every reading after it is taken on, so
it is recorded here and the build number uname -v prints is recorded
beside the readings it first appears in.
A second build of the same source sits beside it in the guest since
2026-09-07, 7.3.0-rc1-release: the same configuration with every
debug option off, built from ~/kernels/linux-7.3-rc1-release, and
chosen at boot by the grub default, which setkernel.sh on the guest
rewrites. It exists because a throughput number is a claim about the
port and a lockdep kernel charges the port for every lock it takes per
block: a profile of the 512 MiB read on the debug kernel put a third of
the reader's samples in lock bookkeeping, more in kmemleak's object
tracking and page zeroing on allocation, and 3% in the module, and the
same read on the release build ran four times faster. throughput.sh
prints the guest kernel and whether it carries PROVE_LOCKING beside
its numbers, and refuses to be read as a rate without that line. A
defect run stays on the debug kernel; a rate is taken on the release
one and says so. The release build has no function tracer, so the
read_folio count that gate prints reads as unavailable there.
Read stat -c %b on the result as well as the checksum. i_blocks is the
on-media count, so it says which branch of the read completion each file
took, and a set of matching checksums proves nothing about which paths
ran.
The write path is exercised on f13.img, a byte copy of f5 made with
cp before every run, so the untouched f5 is the baseline every
comparison is against:
cp f5.img f13.img
virsh attach-disk artix-s6-kde f13.img vdb --targetbus virtio --config
virsh start artix-s6-kde
On the guest, mount without ro, write, sync, umount, remount ro
and read the file back; then power the guest off, detach the image, and
attach it to dragonflybsd642 the same way. DragonFly's cat and
stat, then fsck_hammer2 /dev/vbd1, are the verdict, and the host's
hammer2 show from hammer2-utils over f5.img and f13.img, diffed,
says which chains the flush rewrote and with what transaction ids.
doc/history/verification-record.md records the first such run.
Three things about a write test that a read test never needed:
-
Capture the serial console before the write. A carried
hpanicispanic()here, and that guest sits in a panic with nothing written to its disk, so the message exists only if it left the machine. The guest's command line carriesconsole=ttyS0,115200, and on the hostscript -q -f -c "virsh --connect qemu:///system console artix-s6-kde" \ serial.log </dev/nullrecords the line into
serial.loguntil it is stopped. The first flush panicked, and without this the panic was an ssh connection reset and a guest that came back with an empty log. A panic and a hung-task report reach the line at the default console loglevel; the module's ownhprintflines areKERN_INFOand do not, so write 8 to/proc/sys/kernel/printkfirst if the transcript is to carry them. Two runs recorded only the shutdown for want of that. -
Detach the test from the ssh session. Run it under
setsidwith its output on the guest's root disk, then read the file after; a guest that resets takes the session with it, and a session that ends kills a test still running. -
A hung
syncis not a hung guest. The hung-task detector reports it atkernel.hung_task_timeout_secs, lowered to 20 for these runs, and names the lock and the holder, which is how the first deadlock was read.virsh destroyis then the only way out, and it costs a core like a shutdown does.
The guest kernel carries CONFIG_BLK_DEV_IO_TRACE but no blktrace
binary, and tracefs is not mounted at boot, so the trace is taken with
the block tracepoints directly:
mount -t tracefs nodev /sys/kernel/tracing
T=/sys/kernel/tracing
echo > $T/trace
echo 1 > $T/events/block/block_rq_issue/enable
echo 1 > $T/events/block/block_rq_complete/enable
# ... the writes ...
echo "written, syncing" > $T/trace_marker; sync; echo synced > $T/trace_marker
echo 0 > $T/events/block/block_rq_issue/enable
echo 0 > $T/events/block/block_rq_complete/enable
dn=$(lsblk -nd -o MAJ:MIN /dev/vdb | tr -d ' ' | tr : ,)
grep -E "$dn |tracing_mark" $T/trace
The tracepoints name a device by major and minor with a comma between,
254,16, not by its name, and a filter written for vdb matches
nothing: the first two runs of this reported no events and looked like
an empty write. Print the per-device counts alongside, so an empty
filter shows against the root disk's hundreds. doc/README.status.md
carries the trace for one write and sync, in which the volume header
at sector 0 is the last request and follows a completed flush.
script/fuzz-mount.sh N SEED is the corpus 0.5 asks for: N copies of a
seed image, each with a few bytes changed at recorded offsets, hot-plugged
read-only into the running guest one after another, mounted, listed and
read end to end under the shipped build. A mount may succeed or be
refused and a file may read or fail with EIO; what fails the run is a
WARNING, BUG, oops, hung task or lockdep report in the guest's log,
or a guest that stops answering. The corpus is the generator and the
seed number: every image's mutations are written to the log as
offset:old>new, so a finding reproduces from its seed and index and no
image is kept. Two controls run before the corpus, the seed itself which
must mount with every file readable, and the seed with one bit of its
volume header crc changed which must be refused; a run whose controls
fail is a run whose reader or whose refusal detection is broken, and
its counts mean nothing.
The seed is a small volume, because the mutator samples until it hits a
byte that is not zero and a 2 GiB fixture is almost entirely zero. The
mutator also redraws until the new byte differs from the old, because a
byte replaced by its own value is no mutation and one recorded mutation
in five was, over two seeds, before that check; doc/README.status.md
carries the figures and which generator each recorded seed reproduces
from. It is made on the host by hammer2-utils and populated through the write
path:
truncate -s 64M /mnt/storage/hammer2-fixtures/fz-seed.img
newfs_hammer2 -L FUZZ /mnt/storage/hammer2-fixtures/fz-seed.img
# on the guest, with the module loaded and the image attached:
# mount, create a few directories, files at several
# sizes, a symlink and a hard link, sync, unmount
H2_FUZZ_WRITE=1 is the write side of the same corpus: each image is
attached read-write, mounted read-write, read as above, and then
written into, a new file, 256 KiB of random data, a directory and one
unlink, followed by sync and umount, which is what reaches the
block-table, freemap and check-method sites a read never does. Since
hpanic marks the device in error and returns, a fault is its own
count in the verdict rather than a kernel report, the module is
reloaded after every image because the mark is module-wide, and an
umount or rmmod that does not return 0 fails the run; a BUG, an
oops or a hung task is still a report. The seed control must take
every write. Read on 2026-09-07 at ce59742, seed 1, fifty images:
forty mounted and ten were refused, as the read side reads them;
thirty of the forty refused all four writes with EIO and dirtied
nothing, ten took all four and synced, none faulted, none reported,
none stuck. A mutation in the first 4 MiB lands in the freemap's
reserved zone, and a freemap leaf whose check no longer matches is
refused at the allocation rather than allocated over, which is the
refusal those thirty read.
The image is copied under /var/tmp/hammer2-fuzz for each mutation,
because libvirt takes ownership of a file it attaches and the next copy
over it in the fixtures directory is refused. The script builds the
module against KDIR, exits 2 without a guest, a seed or a kernel tree,
and starts the guest only under H2_FIXTURE_START=1, as the fixture
gate does.
script/f4-roundtrip.sh is F4 as a script: it formats a 2 GiB image on
the host with hammer2-utils' newfs_hammer2, builds the experimental
module, boots the Linux guest to write a tree and its manifest, boots
the DragonFly guest to check that manifest and write a tree of its own,
and boots the Linux guest again to check DragonFly's manifest and what
is left of its own. Every checksum is the writer's, so neither reader
is compared against itself. It refuses to run beside a running guest,
exits 2 without both guests, the tools or a kernel tree, and shuts each
guest down when its turn is over. KDIR names the kernel tree, and
H2_NEWFS and H2_FSCK name the tools when they are not on PATH.
script/cut-flush.sh SECONDS is the interrupted-flush fixture: DragonFly
writes small files to a copy of f5 with a sync every two hundred,
the host destroys the domain after SECONDS, and the cut-off image is
copied. This port mounts one copy read-write, which runs the carried
hammer2_recovery(), reads every file, writes
one more and syncs; DragonFly mounts that result and then recovers the
other copy itself. The header's two tids are printed at each stage, so
a run that cut inside the window where freemap_tid lags is visible;
none has yet. So the script's fourth stage makes that state on purpose:
DragonFly's recovered copy has its header's freemap_tid lowered by
H2_CUT_LAG transactions, four by default, and the sector's two CRC32C
checksums recomputed, and both recoveries run on it; the run fails
unless this port's mount announces freemap recovery over those
transactions and both checkers are clean afterwards. The checksum
routine is checked against the stored values before the rewrite, so a
wrong header layout stops the stage rather than making a corrupt image
that would be refused for the wrong reason.
script/crash-matrix.sh SECONDS is the crash matrix, calibrated against
Kusumi's FreeBSD port on the freebsd15 guest. An 8 GiB volume is made
on the host by newfs_hammer2, so all four volume header zones exist, and
a copy of it is attached to a writer, which mounts it read-write and
runs the same loop as the cut-flush fixture. After SECONDS one of four
things happens: the writing process is killed and the volume unmounted
(kill), the guest kernel is made to panic through sysrq here and
debug.kdb.panic there (panic), the host destroys the domain
(power), or it destroys the domain and then zeroes the second half of
the newest valid header, which is a 64 KiB header write that reached
the media only in part (torn). The cut-off image is judged three
ways: the host's fsck_hammer2, this port mounting one copy
read-write, reading every file, writing one more and syncing, and the
FreeBSD port doing the same to the other copy and
running its own fsck_hammer2; the host checks both results again.
The FreeBSD port writes every cell first, so its recovery of its own
crash is on record before this port is judged against it, and then
this port writes the same cells. H2_CRASH_REPS runs each cell, two
by default; the summary reports a cell green only when every run of it
produced the same verdicts, and names one that did not. The FreeBSD
guest is reached through the freebsd ssh alias as a wheel user with
passwordless doas, and needs the port built from
freebsd-hammer2-upstream and installed; H2_CRASH_CELLS and
H2_CRASH_WRITERS narrow a run. The mirror_tid of every valid header
is printed at the cut, so the torn cell shows which header it destroyed
and which one the recoveries fell back to.
Every script that drives a guest waits for it the same way: a guest
that answers ssh is used whatever the domain says, one listed running
that does not answer is waited on, bounded, because a booting guest
answers within the wait and one shutting down turns to shut off inside
it and is then started, and only one that stays listed running and
silent for the whole wait is given up on, reported as COULD-NOT-RUN
with the host's load average beside it. Reading the domain state
alone was wrong both ways in one day: a guest the fixture gate was
still shutting down read as usable and cost the fuzzer its whole wait,
and a guest a batch reset had just started read as shutting down and
cost the batch both its runs. A guest a script started is shut down
from an exit trap, not from its last line, since a COULD-NOT-RUN after
the start had left one running for the next script to refuse. The
long guest runs are bounded from the host with timeout, H2_RUN_TIMEOUT
seconds and 1800 by default, because a guest whose task hangs keeps
sshd answering and the ssh open; the bound expiring is reported as the
guest hanging, which is a failure and not a skip.
A guest whose task has hung is read before it is reset, not after,
because the reset destroys the only report. ssh is often gone by then:
sshd's fork touches the wedged mount and hangs with it, as it did on
the million-file deadlock. The QEMU guest agent does not, and
virsh qemu-agent-command with guest-exec and capture-output runs a
shell in the guest and returns its output; dmesg, w and t into
/proc/sysrq-trigger, ps with wchan, and /proc/lockdep_stats
all came out of that guest in seconds while ssh had been silent for
minutes. script/guest-dmesg.sh <domain> is that capture, and
million-tree.sh runs it where its run timeout expires, before
destroying the guest, so the report a hang leaves is in the log whether
or not anyone was watching. Anything that touches the mount hangs the
probe too, so the script touches none. Two
samples of virsh domstats --cpu-total --block a few seconds apart
tell a hang from a slow guest first: no CPU time and no writes across
the gap is a hang, and the boot-under-load story is the wrong one.
The trace offsets resolve against the module the run built, which
carries debug lines, with addr2line -i. After virsh destroy the
fixture disk is still attached in the persistent configuration and
wants a detach-disk --config of its own.
script/pfs-domains.sh is the half of 0.8 that belongs to this side.
PFS roots are the port's storage domains, and the milestone checks them
by mounting each by label; which labels a consumer lays down and its
installer are the consumer's, and what a volume written here has to
satisfy is this:
the host formats a 2 GiB image with one root PFS, the Linux guest
creates three more through this port's own ioctl, SYSTEM, STORE
and CACHE by default, mounts each by label as a filesystem of its
own, writes a tree and a manifest in each and unmounts; DragonFly
mounts each by label, checks every manifest with its own md5, lists
the PFSes from a mounted one and runs its checker; the host's checker
runs after each side with its negative control. H2_PFS_DOMAINS
names the roots. Between the writes and the unmount the Linux guest
takes a snapshot of the first root, mounts the snapshot read-write by
its label, changes one file in it and adds another, and reads the
live root's file back unchanged; DragonFly then checks the snapshot's
manifest as it checks the others, and with the live root and the
snapshot mounted side by side reports the changed file reading apart
and the added file absent from the live root. H2_PFS_SNAP names the
snapshot. H2_PFS_VOLUMES=2 formats the filesystem across two 1 GiB
images instead of one 2 GiB image, attaches both to each guest, mounts
by the colon-separated device pair on both sides, fills the first
root with H2_PFS_FILL MB, 1200 by default, so the writes cross into
the second volume, and reads the volume count from volume-list on
each side; the host's checker and its control run over the pair. f7
covered PFSes DragonFly made; this covers PFSes this port
made, which nothing had mounted on DragonFly before, and a snapshot
written into, which the capability declaration's snapshot rows stand on.
script/million-tree.sh is the first of 0.9's criteria that needs
only the fleet. The host formats an 8 GiB image; the Linux guest
writes H2_TREE_FILES files, a million by default, under
H2_TREE_FANOUT directories, each file holding its own path so the
tree is data as well as inodes, with the shell's builtins so the rate
is the driver's and not fork's; then syncs, unmounts, remounts,
drops the page cache and counts, spot-checks two hundred files by
content, and prints the create rate, the sync and unmount times,
MemAvailable at each step, the module's slab, the cold walk time,
lockdep's state and the kernel warnings since the module loaded.
DragonFly mounts the same volume, counts it, spot-checks the same two
hundred files and runs its checker; the host's checker runs after each
side with its negative control. Every reading is a number, which is
what 0.9 asks of each of its rows, and the count on each side must be
the count written. H2_TREE_MODARGS passes module parameters to the
guest's insmod, which is how a control run loads with
nofs_scope=0, the shim's lock scope off; on a write refusal the
script keeps the reclaim and compaction counters, the buddy lists, the
largest slabs and the inode cache size from that moment, which is what
told the order-4 folio limit apart from the scope and the mask.
H2_TREE_GUESTPRE is a command the guest runs before insmod, for a
control that changes the guest rather than the module; the run records
the guest's memory size and whether kmemleak is on, read by a write to
its debugfs node that is refused once it is off. H2_TREE_WRITERS
is the number of shells writing at once, each taking the directories
congruent to its number, so 1 is the serial tree and more is 0.9's
parallel build; each reports its own count and its own first refusal.
H2_TREE_CHURN=1 adds three phases: a tenth of the tree deleted and
written again with hammer2 snapshot taken through it, the snapshot
mounted read-only after the remount, counted and spot-checked by
content on both sides, since what it caught is whatever the churn had
written and the reading is that every file in it holds its own path;
and the whole tree deleted, timed, with the blocks the volume gave
back, which is the store garbage collection reading. DragonFly then
expects the live tree empty and the snapshot at the count this side
found. The delete pass found the directory link-count defect on its
first run. H2_REPEAT=n runs the whole script n times and tallies
the outcomes as test-enospc.sh does, keeping each run's log under
H2_LOGDIR; a lock cycle is a race, so one clean run after a change
to the shim's locking is a run and the tally is the rate. The tally
refuses to report if it saw fewer outcomes than it ran.
script/nix-closure.sh is F6's harness, the first of 0.9's criteria:
a real Nix closure read at a measured cost beside the same read on
squashfs and erofs. The closure is one the host's store already holds,
named by its top-level path in H2_CLOSURE; the host writes its paths
into a squashfs image and, where mkfs.erofs is found, an erofs
image, each cached by the store hash since a closure never changes.
The Linux guest mounts both beside an empty HAMMER2 volume, copies
the closure in with cp -a so hard links, symlinks and modes travel
through the write path, syncs, unmounts and remounts, and takes three
cold readings on each filesystem with the page cache dropped between:
a metadata walk, a full read, and a hashed read that is also the
content check, every file's SHA-256 in path order compared between
the copy and its source, with the symlink targets and the hard-linked
file count compared the same way. DragonFly mounts the volume, counts
it and runs its checker; the host's checker runs after each side with
its negative control. Rates are printed and never judged; a run fails
on a count or hash that differs, a kernel warning, a checker verdict
or a missing reading, and a guest whose run times out is read through
guest-dmesg.sh before it is reset. The guest's whole dmesg is saved
beside the log (H2_CLOSURE_DMESG), because the second warning of a
run is the one that says why lockdep went off, and H2_NC_GUESTPRE
runs a command on the guest before the module loads, for the control
that turns kmemleak off. The same cp -a goes into ext4 on a fifth
disk and ext4 is read cold with the others (H2_NC_EXT4=0 skips
it); the two copy times side by side are the reading the XOP pool
decision turns on. H2_NC_JOBS=n deals the store paths round n
writers that copy at once, for the parallel-build row (a hard link
across two writers' shares arrives as two files, so that count is
reported under more than one writer and not judged), and
H2_NC_GC=0 skips the collection that otherwise follows the reads:
every other store path removed while a reader walks the ones that
stay, what stays hashed against its source, and the count DragonFly
must then see. The guest kernel needs squashfs
and erofs as modules, which the debug guest's did not until F6 asked.
script/throughput.sh takes the two readings that decide whether the
port adds ->readahead and changes its writeback order, the services
DragonFly's buffer cache gives HAMMER2 through cluster_readx() and
cluster_write(). The Linux guest writes one random file, 512 MiB by
default (H2_TP_MIB), from memory to a HAMMER2 volume, to ext4 and
to btrfs on two more disks, btrfs being the checksummed copy-on-write
filesystem a fair comparison needs, times each write with fsync and
two reads at 1 MiB and 64 KiB requests after a remount and a dropped
cache, and checks the HAMMER2 copy by hash. Each file is read once
unmeasured first: the images are files on the host, and the first read
after a write goes to the host's disk while the next hits the host's
cache, a difference of ten times that was read as the driver's before
the priming read was added. Every timed read is therefore cold in the
guest and warm on the host. DragonFly then writes a file of the same
size to the same volume and reads both files cold the same way, which
is the reference for the read rate. The host reads both layouts from
the image with hammer2 show, which prints every data blockref in key
order with its media offset, and reports the count of steps that are
contiguous, forward or backward, DragonFly's file being the reference
the core's allocation comment was written against. A name longer than
the inode's inline field lives in a directory entry, so the parser
resolves the name to the inode number first. Throughput is printed and
never judged; the run fails on a hash mismatch, a kernel warning, a
checker verdict, a wrong block count or a missing reading. The parser
was checked against a fixture file whose five blocks the tool lists as
four contiguous steps, and its first two runs found the tool called
without its subcommand and the name looked up in the wrong block, each
reading zero blocks for both files and failing on the count.
All four judge their image by the host's fsck_hammer2 exiting zero,
and each of those verdicts now carries its negative control beside it,
on the image it judged rather than in a selftest: the same checker is
run on a sparse copy with one volume header byte complemented, at an
offset inside the first CRC section and clear of the magic and the CRC,
and must fail naming the header CRC. A checker that accepts anything, a
wrong binary on the path or a copy that landed elsewhere all read as a
pass without it. The byte is complemented rather than set, for the
reason the fixture gate's manifest control flips rather than sets. The
matrix's torn cell is the same control in live form and keeps it. Three
pass strings were also found to match over an empty population, zero
files checked with zero mismatches and zero unreadable entries out of
zero, and the counts are now asserted with the verdicts.
script/bulkfree.sh measures the one thing a store's garbage
collection depends on that no other run had exercised: on
HAMMER2 a remove frees nothing. The freemap is rebuilt by a scan,
HAMMER2IOC_BULKFREE_SCAN, which the port carried and answered and no
run had asked for, and the scan is two passes by design: the first
moves a block nothing references from allocated to staged, the next
frees what stayed staged, which is what lets a pass run beside
writers without a transaction. The Linux guest mounts a fresh 2G
volume, writes H2_BF_MB one-megabyte random files, removes them,
reads the free count after each step, runs hammer2 bulkfree twice
with a sync between and captures the pass statistics the kernel
prints for each, then writes the set a second time and removes it.
The remove leaving the count where it was is the run's own control:
if the count had moved at the remove, the scan would not be what is
measured. DragonFly mounts the result and runs one pass, which stages
the second set and should free nothing if this side's two passes
were complete; its checker and the host's run after each side with
the usual negative control. The first run asked for one pass, read
nothing freed, and watched DragonFly's pass free the set, which is
the second pass doing its job and the harness's misreading, so the
script says two passes where it says scan. README.status.md has
the readings.
The fixture under /mnt/storage/hammer2-fixtures/tree is five paths: a
file, a symlink, a directory, a directory inside it and a file inside
that. It is deliberately deeper than one level, because the defect that
->iterate_shared found was invisible at the root: the mount root holds a
reference through pmp->iroot that a subdirectory does not, so an
unbalanced reference count only shows below it.
Attach the image as a disk rather than through a loop device. The Artix
guest's kernel is configured by localmodconfig against its own loaded
modules, which trims CONFIG_BLK_DEV_LOOP, and a virtio disk exercises
the 7.3 device-open shim as well:
virsh attach-disk artix-s6-kde /mnt/storage/hammer2-fixtures/f1.img vdb \
--targetbus virtio
mount -t hammer2 -o ro /dev/vdb@TEST /mnt/h2
find /mnt/h2
The attachment is live and not persistent, which virsh dumpxml artix-s6-kde --inactive reports by not naming the image at all. It
survives virsh reset, which is why a run of resets can pass without the
disk ever being in the domain's stored configuration, and it is gone after
a shutdown. Add --persistent to keep it, or expect to attach it again.
A find is the check worth running rather than one ls, since it walks
every directory and reaches each one through a lookup on its parent. At
e76ad21 it returns all five paths and exits 0, umount and rmmod both
return 0, and kmemleak reports nothing after a scan.
Two results in that run were floors and not failures: reading a file
returned EINVAL, ->read_folio not being written, and readlink on the
symlink returned EINVAL, ->get_link not being written, so ls -l on
a directory holding a symlink exited 1 while listing correctly. Both are
written since. A symlink's target is file data on this filesystem, so
->get_link is page_get_link() over the same ->read_folio, which is
how the DragonFly and NetBSD ports read it too, through
hammer2_read_file(). The fixture gate's # link target relpath rows
are the check, f1 carrying the one symlink the fixtures hold.
A clean lockdep run on this meant nothing until 0.4.3: every chain lock
took its class from one init_rwsem() call site, so lockdep reported
recursion at the first mount and cleared debug_locks. Every lock now
carries a class and a nesting level, doc/history/verification-record.md records the
measurement, and the fixture gate reads debug_locks before its first
mount and after its last unmount and fails if it dropped.
The port claims to build against an unpatched Linux, and it runs on the
kernel the consuming distribution actually ships, which is built with its
own configuration and optimization. Those are two claims and the version
pin cannot separate them, since it compares VERSION and PATCHLEVEL and
a patched tree satisfies it exactly as mainline does. Run both and record
both lines:
bash script/test-syntax.sh
KDIR=<the shipping kernel's build tree> bash script/test-syntax.sh
The first takes the unpatched tree, preferring it over a patched one at the
same version, and searches /lib/modules/$(uname -r)/build, then anything
in H2_KERNEL_TREES, then $HOME/kernels/*, then the store. The second
names the shipping kernel deliberately.
The shipping kernel is Saxum's own build, 7.3.0-rc1-saxum, compiled from
CachyOS 7.3-rc1 with -march=znver4 and BBR3. A stock distribution kernel
from a binary cache is not a substitute for it: it measures a configuration
nobody here runs. That kernel's -dev output is in the store and the
module builds against it:
make KDIR=/nix/store/<hash>-linux-x86_64-unknown-linux-gnu-7.3-rc1-dev/lib/modules/7.3.0-rc1-saxum/build
It is built with clang 22 and thin LTO, so kbuild has to be told
LLVM=1 or gcc rejects four of the kernel's own flags; the module
Makefile reads CONFIG_CC_IS_CLANG from the tree's config and adds it,
so the line above is enough. The result carries vermagic
7.3.0-rc1-saxum and loads on nothing else. No guest boots that kernel
yet; the Saxum server edition, which will, was not built when this was
written, so the fixture gate still runs on artix-s6-kde at plain
7.3.0-rc1. The store's 7.3.0-rc1-cachyos figures are a superseded
measurement rather than a standing requirement.
Both the header line and the summary line carry the release string and
either mainline or patched, read from the tree's EXTRAVERSION:
anything left after stripping a leading -rcN was added by whoever built
the tree. A kernel built here with its own optimization carries a suffix
too and so classifies without being named.
The summary line is the one that gets quoted into a document, which is why
it carries the tree rather than the release series alone. A quotation that
named only the series was written into README.status.md describing a
mainline tree while the run behind it had used the store's patched one.
The same distinction applies to the module. make KDIR=<tree> writes the
tree's release into vermagic, and a module built against one kernel is
refused by another before any of its code runs, so the runtime test needs a
module built against the kernel it will be loaded on. The shipping kernel's
module directory is its release string, 7.3.0-rc1-saxum, and installing
modules anywhere else leaves them where the running kernel does not search.
Building against that kernel inherits its flags through kbuild, including
-march=znver4, so the resulting module requires a Zen 4 host.
The kernel of record moves faster than any guest in the fleet, so testing against it means installing a kernel rather than finding one. Two routes are known to work and neither needs a kernel build.
Fedora carries the current stable series and the development series side by side, and its kernel packages have shallow enough dependencies to install across a release. Both of the kernels this port has been loaded on came from there, into a guest that was running Fedora 44:
# dnf --releasever=45 --enablerepo=updates-testing -y \
install kernel-7.2.3-300.fc45 kernel-devel-7.2.3-300.fc45
# dnf --repofrompath=raw,https://dl.fedoraproject.org/pub/fedora/linux/development/rawhide/Everything/x86_64/os/ \
--repo=raw --nogpgcheck -y install kernel-<exact-nevr> kernel-devel-<exact-nevr>
# grubby --set-default /boot/vmlinuz-<version> && reboot
Name the exact version. dnf install kernel against a repository that
already has some kernel installed reports Nothing to do and exits 0,
which reads as success and installs nothing. List first, with
list --showduplicates, and install what the listing names.
Installing across a release upgrades what the kernel package depends on. The 7.2.3 install above pulled Fedora 45's glibc, gcc and openssl into a Fedora 44 guest. That is fine for a disposable test guest and is worth knowing before doing it to one that is not.
The second route is the chaotic-cx/nyx nix flake, which packages the
CachyOS kernels and had a cached 7.3-rc1 build on 2026-09-03. It is a
substitution rather than a build. The CachyOS pacman repository is a
different channel with its own cadence and had no 7.3 kernel on the same
day, so a reading of one says nothing about the other.
An error path nobody has driven is an untested branch wearing the costume of a safety net: it reads as defensive prose rather than as code, so it is the last thing anyone thinks to exercise. Every such branch was driven on 2026-08-26, in a scratch copy of the tree, by removing the input each one names. The count is deliberately not written here: it would be a second claim about the same population as the table below, with nothing checking it, and this file has already recorded one instance of a count and a list disagreeing. Read the table.
| gate | branch | how it was driven |
|---|---|---|
test-citations.sh |
no doc/*.md |
the directory moved aside |
test-provenance.sh |
no origin clone, so no carry re-verified | H2_CLONE_DIR pointed at a path that does not exist |
test-history.sh |
not a repository | .git moved aside |
test-history.sh |
no roadmap | the file moved aside |
test-inventory.sh |
no src/sys/fs/hammer2 |
moved aside |
test-absence.sh |
the population is empty | doc/ and src/ moved aside |
test-absence.sh |
no claim matched, so the pattern has stopped | the phrase it matches renamed in a scratch copy of the gate |
test-absence.sh |
a claim naming a symbol that IS defined | hammer2_chain_lookup() and hammer2_chain_scan() appended to README.porting.md, the second wrapped across two lines |
test-absence.sh |
a claim naming a ->method that IS wired |
--selftest, on a fixture tree rather than on this repository |
test-absence.sh |
no ->method claim matched, so that pattern has stopped |
--selftest |
test-inventory.sh |
no test/ |
moved aside |
test-doc-prose.sh |
a finding in a root document | a British spelling appended to README.md |
test-doc-prose.sh |
the population narrowed back to doc/ |
the file list filtered in a scratch copy of the gate |
test-checkpatch.sh |
no baseline | moved aside |
test-checkpatch.sh |
no checkpatch.pl |
CHECKPATCH at a path that does not exist |
test-checkpatch.sh |
no perl | a PATH assembled from store paths holding none |
test-vectors-contract.sh |
a vector file missing | moved aside |
test-shim.sh |
no compiler | CC naming one that does not exist |
test-syntax.sh |
no kernel build dir | KDIR at a path that does not exist |
test-posix.sh |
no shell realized | H2_DASH and H2_BUSYBOX at paths that do not exist |
test-doc-prose.sh |
no vale | a PATH holding none, driven 2026-09-02 |
test-fixtures.sh |
no image, no guest, no KDIR |
each driven by pointing the variable at a path that does not exist |
test-fixtures.sh |
a manifest that does not match the media | one hash altered in f5.manifest, which failed the image and named it |
test-fixtures.sh |
the comparison itself cannot fail | --selftest, and a per-image control on every run |
test-fixtures.sh |
a module built for another kernel | the default KDIR, which is the host's, against a guest at 7.3.0-rc1 |
test-enospc.sh |
a lockdep shutdown that no captured banner attributes | nothing; the run counted every shutdown as the cycle it was written for, and now reports the banner and exits 2 when none names it |
test-enospc.sh |
a run against a guest still holding a wedged module | its own second run, which reported five failures about a filesystem that had never mounted; the setup steps now report themselves and exit 2 |
test-enospc.sh |
a guest listed running that never answered ssh | the host load average, twice, with nothing about the guest; the refusal now prints every vCPU's instruction pointer, the disk requests over three seconds and whether the guest agent answers, read from outside before the exit trap shuts the guest down |
root-boot.sh |
a boot that never mounted the volume | a second boot against a label the volume does not carry, which stops at the mount and does not reach PID 1 |
root-boot.sh |
a checksum comparison that cannot fail | its first run, where an unanchored sed made the two sides unequal by construction |
test-fixtures.sh |
an ioctl that answers with the wrong errno | the recorded results, which caught EOPNOTSUPP where Linux wants ENOTTY on the first run |
test-fixtures.sh |
a scan that returns success having read nothing | the PFS and volume counts, which must be non-zero, and which caught a zeroed capacity |
test-fixtures.sh |
more images than there are target names | 26 manifests with an image beside each, which reports the ceiling rather than attaching over the last |
All exit 2 and name what was missing. Two defects fell out of driving
them: test-shim.sh was the only gate whose message omitted the
COULD-NOT-RUN prefix, so anything scanning output rather than status
would have missed it; and test-posix.sh had no way to reach its own
no-shell branch, because the store lookup finds a shell on any machine
that has one, which is why the override exists.
An absent tool must decline, never pass. A probe whose success is
cheap to satisfy trivially - an absent binary above all - reports a clean
run having examined nothing, and the summary looks identical either way.
Measured 2026-08-26 by naming a compiler that does not exist:
test-shim.sh, test-syntax.sh and test-vectors-contract.sh each exit
2 and name what was missing, and the vectors gate says which half it still
completed. test-checkpatch.sh exits 2 with no perl under a PATH
assembled from store paths for coreutils, sed, grep, diff, awk and bash,
which contains no perl.
That fourth one was first recorded as UNVERIFIED, on the grounds that
emptying PATH breaks the shell and perl cannot be hidden by directory
because it shares one with everything else the gate needs. Both facts are
true and the conclusion was wrong: a nix machine keeps each tool in its
own store path, so a PATH without perl is assembled rather than
subtracted. A record that something cannot be checked is a claim about
an instrument and decays like any other. The form that survives is
UNVERIFIED BY THIS ROUTE with the route named, so that the next reader
can see which route was asked and whether another exists.
Exit 2 from any gate here means the instrument could not run: no
compiler, no kernel headers, no checkpatch.pl, or a population that came
back empty. That is not a verdict on the code, and it should not be
recorded as a failure.
test-vectors-contract.sh belongs to neither group. It
asserts that this repository still keeps the promises the next section
describes: the -DXXH_VECTORS_CONTROL hook, the uppercase hex constants,
the printf that writes Castagnoli ... MATCH, and the printf that
opens a line with XXH64 and carries want. Where an xxHash is
available to link against it also runs the vectors and requires exit 0,
then compiles with the control define and requires nonzero, because a
status only ever observed as 0 is not tested. That run is also where the
wording is read out of the program's own output rather than only out of
its source, since stdout is the surface the consumer reads: a printf
left in the file but reached under a condition that never holds passes
every source check and prints nothing. It carries a negative
control that runs every time: a lowercased copy of the file must fail the
comparison, since a case-sensitive check and a case-insensitive one look
identical while both are passing, and only the case-sensitive one catches
the defect that actually happened.
Every pattern in it is anchored on the code rather than on a token. These
files describe their own contract in comments, so a check for
Castagnoli.*MATCH matched the comment quoting it and stayed green after
the printf was deleted. That was found by running the control, not by
reading the gate.
script/test-enospc.sh fills a 2 GiB volume until the first write fails,
calls sync(2), and reads debug_locks on both sides of each step. The
circular lock dependency it was written to reproduce is fixed, so is
the fault that left the module unremovable after a fill, and so is the
held lock freed during the fill that it captured whole once it kept its
log. It records what fsync(2) on the last written file and
syncfs(2) on the volume return after the fill, with the error text
kept, because an exit status of 1 from a missing path and one from a
failed sync read the same. It hashes every file as it writes it, off
the volume, drops the page cache after the sync and checks every file
from the media, printing the intact and damaged counts together so a
check that read nothing cannot pass as one that found nothing wrong;
that check is what found a fill losing nearly all of itself.
H2_ENOSPC_FILES=n caps the fill short of full, which is the control:
a volume with room must read back everything it accepted, or the check
is what is broken. Free space is read after the sync, because the write
path refuses a fill while statfs still shows the space its dirty pages
will take; the free count is printed beside it, since statfs subtracts
the whole reserve and reads zero for anything under a twentieth of the
volume. Around that sync it reads the freemap's two allocation
counters, the bytes handed out for data and for everything else,
which the module exports as alloc_data_bytes and alloc_meta_bytes,
and after it prints the last refusals the write entry put in the
debug log, each with the free count and the dirty bytes it judged by,
the module's debug prints being on for the run; what the sync took
against what the count promised is read from those lines, beside the
count of data blocks given new media, the blocks assembled around a
folio smaller than the block, the block folios the write entry could
not allocate, and the guest's free pages by order. The image
of a failed run is kept beside the next run's as enospc.img.failed,
since its freemap, read on the host with hammer2 freemap, is what a
loss is diagnosed from. After the fill it writes 128 KiB through a shared mapping of
a file sized while there was room, with test/hammer2-mmap-exercise.c
in its existing mode, and 128 KiB through write(2) into another
such file, and fails the run if write(2) is refused and the mapping
is not; the refusal at the fault is a SIGBUS, 135 from the guest's
shell. The fill ends in 64 KiB pieces so that less than either probe
is left above the threshold. The reserve refuses a user with twice
the free space it refuses root at, so H2_ENOSPC_USER=1 runs the fill
and both probes as nobody under setpriv, H2_ENOSPC_MODARGS is
handed to the guest's insmod so io_buf_only=1 puts the whole fill
through the DIO layer's block buffer and its bio writes, and, with
H2_LOCKDEBUG=1, fail_alloc_after=N has the freemap allocator refuse
every allocation past the Nth, which is how the paths behind an
allocation failure are run now that the reserve keeps a fill from
reaching one, and that build prints every chain still allocated at the
unload, with its type, key, references, flags and parent; debug_hpanic=1
has the mount helper call hpanic so the fault path is exercised on
demand, and debug_hpanic=2, on the same debug build and writable
after loading under /sys/module/hammer2/parameters/, has sync_fs
call it on the second call after the knob is set, which is the first
sync(2) once the knob is set after an earlier sync, so what that
earlier sync wrote is on the media and what comes after the fault is
not: script/hpanic-contain.sh is that reading, twenty files synced,
the knob set, twenty more written, a hard stop, fsck_hammer2 on the
host and the two counts at a remount, run once with the knob and once
with H2_KNOB=0 as the control, and on 2026-09-07 it read 20 and 0
with the knob and 20 and 20 without; debug_hpanic=3 fires once from
hammer2_base_insert on the next block-table insert, and the same
script with H2_KNOB=3 is the acceptance reading for the returning
hpanic: the guest pauses after its first sync so the host can copy
the image, then the knob, one file, a sync that must fail with
EIO, a create that must be refused, the mount options, umount and
rmmod, the device hashed through O_DIRECT before and after, the
log counted for BUG, oops and WARNING, and the host names each
64 KiB block that differs from its copy; H2_ACCEPT_CONTROL=1 H2_KNOB=0 runs that sequence without the fault. On 2026-09-07 the
reading was EIO, refused, ro, 0, 0, identical, fsck_hammer2
clean and no block changed, against the control's 0, 0, rw, five
blocks changed; the two runs before it, which read the device
changed and the freemap leaf's check bad, are what found the block
device's own writeback and the header write, recorded in
README.porting.md; and every run reads both
thresholds after the sync with one 64 KiB write as the user and one
as root: the user is refused in either mode, root is accepted after a
user's fill and refused after its own, and a run where root reads the
same either way has one threshold where the driver claims two. The
readings sit after the sync because the refusal counts dirty pages,
and writeback between two writes moves that count by more than the
gap between the thresholds; after the sync the pages are allocated
blocks and what is free is under the fill's threshold plus one step.
They take fresh names: the first run reused the name the refused
user write had left behind, and the guest's fs.protected_regular
had the VFS refuse root that open, EACCES on a file another user
owns in a sticky world-writable directory, which read as the reserve
refusing root. Every run prints the source hash it was built from,
with a dirty mark.
doc/history/verification-record.md carries the account.
It became a gate after ten clean runs on the build that passes it,
across three shapes of the instrument, all in the account. It exits 2
without a guest, like test-fixtures.sh, so a push from a machine
without the fleet reports it as not run rather than as passed. Its
first run as a gate found that its readings named only the faults it
was written for, so the warning from the compaction daemon that the
file mapping did not implement folio migration sat in 39 of 62 kept
logs and every one read as clean. It now counts every cut here line
in the capture as a kernel warning, prints the first, and fails on
one; the selftest holds the pattern against the line the kernel
prints. Every WARN prints that line, whichever subsystem it is from,
so a warning the readings do not name is still counted. The count is
scoped to the capture after the module loaded, because the capture is
the ring from boot and the first run of the count found the guest
kernel warning for itself there, a DMA allocation in the USB host
controller during boot, untainted and before the module. When the
count is not zero the first warning's trace is kept in the log, which
the run that found that one had not done.
A debug build logs each PFS as it is freed and each one the teardown
syncs, unhashed with %px so the addresses can be compared against the
one an oops prints. That is what turned "a chain points at a freed PFS"
from a reading of the code into a matched address. It is behind
HAMMER2_LOCKDEBUG, so a normal build carries neither the print nor the
raw pointer.
Two readings around that fault were wrong in the same way and are worth naming, because the shape recurs: they took the first match in the whole log rather than the one belonging to the oops, so a healthy run reported a faulting instruction and a faulting register out of a page-allocator warning printed long before, and did it while reporting no oops at all. They are scoped to the oops now.
The reproducer carries its own control now, which every gate in this
tree had and it did not. sh script/test-enospc.sh --selftest checks each
reading in three directions against a line the kernel really prints: the
pattern must match that line, must not fire on a healthy run's log, and
must appear in the half of the file the run actually uses. That last one
matters because the remote block is one quoted string and cannot share a
variable with the host half, so the patterns are copies and copies drift.
The check searches the file with its own block stripped out, since a
search whose pattern sits in its own command line finds itself: the first
version did exactly that and would have passed for ever. The control runs
at the start of every real run rather than when someone remembers it,
because a reading that has quietly stopped matching is the failure this
script has actually had, three times.
A batch also refuses to continue if the script changed underneath it. Every iteration re-reads the file, so an edit made while a batch is running silently changes the runs after it, and a half-written file gives them a syntax error that the tally scores as a driver failure. Two runs of a six-run batch were lost that way before the checksum existed.
The capture window used to close before the thing being measured. The
run streams /dev/kmsg from before the module loads, and it stopped that
stream immediately after the sync(2), which is several steps before the
unmount: every message the unmount produced was therefore invisible, and
readings taken after it were reading a log that had already ended. The
first batch to ask what the module still held reported, on four runs
whose rmmod had succeeded, that ->kill_sb never ran,
which cannot be true and is what exposed it. Reading the log never
required stopping it, so the capture now runs on until the unmount is
over, and it runs unbuffered, because cat block-buffers to a file and a
line printed during the unmount can sit in that buffer while a grep for
it reads as a line never printed. Which form ran is reported per run.
What the module still holds is now printed by the driver rather than
inferred. hammer2_assert_clean() reads the four allocation counters on
the unload path, which a module that will not unload never reaches, so
the one failure they would explain is the one they could not report.
->kill_sb prints them too, unconditionally: that function runs only
when the superblock is actually destroyed, so a failing run with no such
line says the superblock is pinned rather than that the counters were
clean. Printing only a nonzero count would have made those two cases
produce the same silence, which is what the first version of it did.
Both of those are intermittent, which makes a single run the wrong
instrument: it answers about itself and nothing else. H2_REPEAT=n
runs the whole thing n times and tallies pass, fail and could-not-run,
printing per run the readings that tell the two apart, so an intermittent
fault is a rate rather than an anecdote. Each iteration resets the guest
first, because a guest left wedged by one run costs the next one as well:
three runs were spent that way before this existed. The tally asserts it
counted as many outcomes as it ran, so a loop that lost one cannot report
a clean rate.
One reading it no longer takes on trust is the unmount. For several runs
the script judged the unmount by the exit status of the umount process,
which is killed on these runs by something outside the script, and read
that as an unmount that never finished. It asks whether the filesystem
went away instead, and on the runs where it has asked, the filesystem
went away every time.
Every other write measurement in this tree was taken on a volume with room in it, which is why this went unfound through the whole write path, the crash matrix and the round trip. A filesystem's behavior when it runs out of space is its own surface.
Reading the report is the part the script had to be rebuilt for. It
streams /dev/kmsg to a file for the whole run and prints what it
derives from the log before it attempts the unmount, because the ring
wraps before a fill ends and a bad unmount is where the evidence is
lost. Five reproductions before that produced no readable report. The
capture is doc/enospc-lockdep.txt, which records a defect since fixed
and says so in its own first line, because a file holding a lockdep
report reads as current behavior to anyone who opens it.
Two things it now refuses to assume. Reports are counted by banner
occurrences rather than by lines matching DEADLOCK or circular,
which counted one report as three. And debug_locks reading 0 is not
taken for the cycle on its own, since an unlock imbalance, a held lock
freed and three lockdep resource ceilings read the same there: the run
reports which banner named the shutdown, and a shutdown it cannot
attribute is COULD-NOT-RUN rather than a confirmation.
The instrument the defect needed is in the tree rather than rebuilt each
time. HAMMER2_LOCKDEBUG=1 on the make line compiles
hammer2_dbg_held_chains() live, which walks the calling task's held
locks, resolves each chain lock back to its chain and prints the type,
key, class, recursion depth and lockcnt. Unset, every call compiles
away and the module carries no symbol for it, which is checked by
building both ways. H2_LOCKDEBUG=1 bash script/test-enospc.sh builds with
it and summarizes what it printed.
Two designs it deliberately is not, both of which were built and both of
which read as evidence while carrying none: one recording the acquire on
every lock, which names where a chain was last locked rather than where
the live one came from, and one clearing that record on unlock, which
erases it for a counted lock that is still held. This one
reads CONFIG_LOCKDEP's own held-lock records, so it cannot disagree
with the report it is being used to explain, and it prints nothing when
nothing is held: a quiet run is the absence of a finding rather than a
passing one.
Its own first two runs failed on the script rather than on the driver, and the second is the interesting one. A guest left wedged by a previous run of this same script cannot load the module, and every check after that reported a failure describing a filesystem that had never been mounted. The setup steps now report themselves and the run exits 2, so the state this script leaves behind is told apart from the defect it exists to find.
script/dfly-enospc.sh runs the same fill on the DragonFly guest, against
the tree the patches under doc/upstream/ are written for, so a defect
this port finds in carried code can be shown on the code's own kernel
before it is filed. It needs a kernel built from DragonFly source with
two sysctls added to hammer2_freemap.c, vfs.hammer2.fail_alloc_after
and vfs.hammer2.alloc_count, which doc/upstream/README-provenance.md
describes; on the release kernel the allocator never refuses and the run
says so. The refusal is lifted after H2_KNOB_LIFT seconds, 120 by
default, because a refusal left in place on DragonFly wedges the syncer
on the buffers it cannot write and the fill never reaches the unmount.
The reading is the writers' last error, the file count before and after
a remount, the kernel messages the fill produced, and fsck; its two runs
so far are recorded in the provenance document beside the patch they
verified.
script/root-boot.sh is the boot as a script. It formats a 4 GiB image
with newfs_hammer2, builds the module, and uses the guest to put a
static init on the volume and to run two checks the guest is needed for:
a binary copied onto the volume and executed from it, and a 128 KiB
file, two of this port's 64 KiB folios, written entirely through a
shared writable mapping and msynced, then compared on media after
drop_caches and again after a fresh mount. It then boots
qemu-system-x86_64 directly, with an initramfs holding the module and
that same init, which reads root= from the kernel command line,
mounts the volume, moves the mount over / and executes /sbin/init
from it. Six lines of the boot's own transcript are required, from the
module load to PID 1 writing and reading back and remounting read-only.
The control is a second boot against a label the volume does not carry, which must fail at the mount and must not reach PID 1. It is placed there because every claim the first boot makes rests on that mount, and because the boot narrates itself: a check that grepped only for the last line would pass against a kernel that mounted nothing.
Its first run failed on a defect in the script rather than in the
driver. sed -n 's/^mapped sum //p' also matches the mapped sum after remount line, so the first checksum came back as two lines and could
never equal the second. Both patterns are anchored on the whole line
now. This is the shape rule 24 names: a matcher that reads more than it
means, in a comparison that can only fail.
It exits 2 without qemu-system-x86_64, /dev/kvm, cpio,
newfs_hammer2, a kernel image or the fixture directory, and it names
which. KDIR supplies the kernel image by default, so it wants the
kernel of record and not the host's headers; H2_BZIMAGE overrides.
What it does not show is a distribution. Nothing on the volume but the init has ever run: no service manager, no package manager, no shared-library loader.
A fixture is not shown to work by its own green run, and reading a fixture you just wrote is the least reliable way to answer whether it tests anything. So each of these was run against the defect it exists for rather than merely run. This is a LIST OF WHAT WAS DONE, not a claim that everything has been done: an "every check has been falsified" sentence is a claim about a population that grows, so it would be false the moment a check is added rather than eventually, and nothing would notice. A new check comes with the run that showed it failing, added here.
| falsified on | check | how |
|---|---|---|
| 2026-08-26 | xxh64: -DXXH_VECTORS_CONTROL hook present |
the #ifdef replaced with #if 0, comment left in place |
| 2026-08-26 | xxh64: constants uppercase |
the constant lowercased |
| 2026-08-26 | xxh64: HAMMER2 seed uppercase |
that constant lowercased separately, because sharing a code path with something falsified is not being falsified |
| 2026-08-26 | crc32c: a printf writes 'Castagnoli' then 'MATCH' |
the printf reworded, comment left in place |
| 2026-08-26 | xxh64: a printf opens with XXH64 and carries 'want' |
the prefix renamed to XXHASH64 in the source |
| 2026-08-26 | the wording negative control | the printf line stripped from a copy, which must stop the pattern matching |
| 2026-08-26 | the output opens lines with XXH64 and carries 'want' |
the printf guarded by if (0), so the source check passes and stdout is empty. Falsified separately from the source check for that reason: under a rename the gate exits on the source failure and never reaches this one |
| 2026-08-26 | the vectors negative control | the shared matches() made case-insensitive |
| 2026-08-26 | an overridden run says so in its summary |
the override warning deleted, and again partially |
| 2026-08-26 | a UAPI-shaped tree claiming 7.2 is COULD-NOT-RUN |
a version.h fallback added, the improvement a later maintainer plausibly writes |
| 2026-08-26 | the checkpatch selftest | the sha256 mismatch text deleted |
| 2026-08-26 | posix: each shell rejects a probe |
every probe body replaced with echo, which reports the shell inert |
| 2026-08-26 | posix: a plain script is accepted |
the plain script made unparseable, which reports a clean result unreachable |
| 2026-08-26 | test-checkpatch.sh declines without perl |
run under a PATH assembled from store paths holding no perl |
A DATE REPAIRS AGING AND NEVER FALSIFICATION, and the two get treated as one thing. A dated completeness claim still says something false the moment the population grows: the date stays true while the sentence stops being, so nothing about it looks stale. That is why this is a table of what was done rather than a dated sentence about everything.
No gate derives this table, and that is deliberate. The population is checks inside scripts. Counting invocations by grep is the hand-maintained-list defect one level up wearing a regex, and running every gate from inside another gate to read its printed count couples the gates for a claim that is documentation rather than behavior. Written here rather than settled in conversation, because a decision not to build something is invisible to the next reader unless it is in the tree they grep. The specification repository reached the same answer about its own typed list for a better reason: extensionless documents have no extension to enumerate on.
The syntax selftest's second direction, that an unoverridden run carries no override warning, was left out of that table while it was vacuous: with no kernel of record installed, an unoverridden run is COULD-NOT-RUN and carries no warning either way. It stopped being vacuous on 2026-08-26, when such a run began really compiling.
A test file nothing runs reads exactly like a test file that passes, so
these two were written up on 2026-08-26 as staged and unrun. That was
wrong within the hour, and wrongly reassuring in the direction that costs
most: no gate HERE runs them, and Saxum's
scripts/test-hammer2-checkalg.sh compiles both, against the FreeBSD
port's vendored xxhash and its icrc32.c, reaching this tree through
LINUX_HAMMER2. The sweep that concluded "run by nothing" searched this
repository only, which is the whole of the mistake: a consumer one
directory over answers a question no local grep can.
What that makes them. Four things are an interface with a consumer that
cannot be seen from here: the exit status of each file, the wording of the
Castagnoli ... MATCH line, the XXH64 prefix and want that the consumer
counts its vectors by, and the uppercase hex of the xxh64 constants. The
rewrite that fixed their logic lowercased one constant, Saxum's negative
control seds on that literal, and its gate spent an hour reporting
correctly that it was comparing nothing. -DXXH_VECTORS_CONTROL exists so
that control never has to depend on this file's text again.
No gate here reaches into another repository to check any of that, and
none should: the port stands on its own. The contract is written down
instead, in the table below and in each file's header, and
script/test-inventory.sh fails on any file under test/ that neither a
gate names nor this table lists.
| file | run locally when | state on 2026-08-26 |
|---|---|---|
test/crc32c-vectors.c |
iscsi_crc32(), which arrives with the check algorithms in 0.2 |
the exit status accepted either CRC-32C or CRC-32 IEEE, so the one question it exists to ask went unanswered while it reported success. Now it accepts Castagnoli only, and names IEEE when it sees it |
test/xxh64-vectors.c |
an xxhash.h in this tree, same import |
two of three cases asserted nothing, and the seeded case used xxHash's golden-ratio prime where HAMMER2 seeds with 0x4d617474446c6c6e. Four vectors now, all four measured against xxhsum 0.8.3 and libxxhash 0.8.3, and compiled and run green against the system xxHash on 2026-08-26 |
test/getdents-resume.c |
a directory can be listed, which it can, on a guest with a mount | ->iterate_shared resumes across calls, which one ls cannot exercise: a 32 KiB buffer takes a small directory in a single call, so the branch that stops mid-directory never runs. It reads with a 64-byte buffer, one or two entries at a time, and fails on a runaway rather than hanging. Against the five-path fixture on 2026-09-04: the root gives 5 entries over 3 calls and the subdirectory 3 over 2, each name exactly once |
The first two are not wired into a gate HERE, because there is nothing in this tree to link either against; Saxum links them against the BSD tree instead. They get a local gate the day 0.2 imports the algorithms, and the inventory gate is what remembers to ask. The third needs a booted guest holding a mount, so it belongs to the read-only fixture gate the roadmap names and is run by hand until that exists. Until then, changing either file's output shape or exit status breaks a gate in a repository this one does not reference.
A volume created by DragonFly's newfs_hammer2, mounted here, compared
file by file; then the reverse. HAMMER2 writes an XXH64 digest into every
blockref and every implementation verifies it, so a subtly wrong port
produces volumes that read as corrupt on DragonFly rather than as buggy.
The cross-implementation round trip is the only test that catches that,
and no amount of self-consistency substitutes for it.
Every gate here reads a file that is not changing while it reads. The gates 0.3 and after do not: a build-and-load script watches a guest boot, and the crash matrix watches a filesystem being cut off mid-write. Those gates can fail in a way none of the current ones can, by sampling before the phase they are about begins. A guest that has not yet loaded the module reads clean for the same reason a healthy one does, and the sample carries no timestamp saying which it was, so nothing in the output distinguishes them afterwards.
It does not feel like guessing, because the activity is measuring, and it is measuring, of the wrong phase. The reading that survives review is the one that happened to be right anyway, which is invisible to anyone checking answers rather than method.
So a gate that samples a running system carries a positive control: something
that MUST move during the phase under test, sampled in the same pass as the
measurement. A module load that has begun has a dmesg line; a write that
has begun has a growing device. An unchanging number with no such control is
unproven rather than reassuring, and a gate resting on one is asserting its
own conclusion.
This is the same requirement as the negative controls above, one axis over. Those ask whether the instrument can fail at all. This asks whether it was looking while there was anything to see.