Add POSTMORTEM.md: every real issue found, how, fix, and regression prevention
CI / build-and-test (push) Successful in 48s

A detailed, permanent record covering the whole arc of this project's
development so far -- requested explicitly, as detailed as possible, to
prevent regression for ever. Seven sections:

1. Performance findings: the file-index O(n^2) treap rewrite, pack_write's
   O(n^2) dedup + latent hash-collision correctness bug, and the mount
   table's O(n^2) (confirmed, deliberately not fixed, with the reasoning).
2. Environment/tooling limitations: TSan's categorical block (every
   workaround actually tried and ruled out, not just the ones that
   worked), and both variants of the ASan/UBSan sandbox-startup flake.
3. Project professionalization: version API, SPDX, pkg-config, SECURITY.md,
   CHANGELOG.md, the Gitea-not-GitHub migration, and the CODE_OF_CONDUCT
   decision.
4. Git identity correction: the filter-branch rewrite, the tag-object
   tagger field it missed (found only by a full object-database sweep,
   not by re-reading git log), and the exhaustive re-verification.
5. Data-integrity fault injection: test_crash_consistency.c, the real bug
   in its own oracle (128 false failures before a progress side-channel
   fixed it), and the two real overlay.c bugs found while building the
   write-failure test (silent journal failure, torn-record poisoning of
   later records).
6. CI as a second, independent reviewer: the memory leak detect_leaks=0
   had been masking locally, and the set -e scripting bug -- including
   the mistake made while fixing it (reintroducing the same bug in a new
   shape), the follow-on bash-variable-blowup bug, the newly-discovered
   TSan segfault variant, and the retry-budget gap, each confirmed by
   direct reproduction under bash -eo pipefail, not reasoned about.
7. Distilled lessons: ten patterns extracted from the above, written to
   be applied to a new problem, not just recognized in this one.

Every figure cited was checked against BENCH.md/CLAUDE.md's own numbers
before writing this, not reproduced from memory.

Cross-referenced from CLAUDE.md's "Repository status", README.md's
"Contributing" section, and CHANGELOG.md -- including two entries CHANGELOG
itself was missing (the set -e CI fix from the previous commit, and this
document), the exact class of gap POSTMORTEM.md section 3 already
describes happening once before with the v0.1.0 tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqJpkdJ6Njnt1pw3CbghzB
This commit is contained in:
2026-09-14 21:02:58 +00:00
co-authored by Claude Sonnet 5
parent c414a27e44
commit 7661c94105
4 changed files with 746 additions and 2 deletions
+31
View File
@@ -67,6 +67,37 @@ pushed to any remote — there is no public release yet, only a local one.
fluke), not just this project's own — same root cause
(`personality(ADDR_NO_RANDOMIZE)` blocked by seccomp) confirmed both
generically and against this project's actual test suite.
- **`POSTMORTEM.md`**: a detailed, permanent record of every real bug and
environment issue found across this project's development, how each was
actually diagnosed (including false starts), the final fix, and the
regression-prevention artifact for each — written so a future session
doesn't have to re-diagnose something already answered there.
### Fixed (CI)
- **`.gitea/workflows/ci.yml`'s ThreadSanitizer step was failing CI for an
environment reason, but the actual bug was in the CI script, not the
code under test.** Gitea Actions' `run:` steps execute under `set -e`;
an unwrapped `OUT=$("./binary" 2>&1)` aborts the whole script on the
first nonzero exit *before* the step's own classification logic (which
exists specifically to not fail the build over the already-documented
TSan-can't-start-here limitation) ever runs. Fixed by wrapping every
such invocation in `if cmd; then RC=0; else RC=$?; fi`. Fixing this
surfaced two more real issues, both also fixed: capturing unbounded
flake output into a bash variable (rather than a file with a bounded
read-back) caused a genuine misclassification at scale, and a second,
previously undocumented segfault variant of the same TSan-can't-start
flake (confirmed via hitting multiple unrelated binaries at random, not
a per-binary bug). The ASan/UBSan step's retry budget was also raised
from 3 to 5 after a real 3-in-a-row flake exhaustion was observed during
this same verification work. See `POSTMORTEM.md` §6.2 for the full
diagnosis, including the exact reproduction technique used to confirm
each fix (not just reasoned about).
- A `-Wunused-result` warning on `tests/test_crash_consistency.c`'s
intentionally-ignored `write()` return value built clean locally (a
`(void)` cast) but still warned on the Gitea runner's own gcc — a real
toolchain configuration difference, confirmed by reproducing clean
locally with identical flags first. Fixed with an actual conditional
branch on the return value instead, which is portably honored.
## [0.1.0] - 2026-09-14
+1 -1
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Repository status
This repository contains a working v0 implementation of the `concept.md` specification: `include/packfs.h` (public API), `src/*.c` (implementation), `tests/test_*.c` (test suite), and open-source project scaffolding (`README.md`, `LICENSE`, `CONTRIBUTING.md`, `CHANGELOG.md`, `SECURITY.md`, `packfs.pc.in`, `.gitea/workflows/ci.yml` — this project is hosted on Gitea, not GitHub), alongside the frozen `concept.md` and this file. Every file under `src/` and `include/` carries an `SPDX-License-Identifier: MIT` tag; `include/packfs.h`'s `PACKFS_VERSION_*` macros are the single source of truth for the project's version — `pfs_version()` (runtime) and `packfs.pc` (generated by `make install`) are both derived from them, never maintained separately.
This repository contains a working v0 implementation of the `concept.md` specification: `include/packfs.h` (public API), `src/*.c` (implementation), `tests/test_*.c` (test suite), and open-source project scaffolding (`README.md`, `LICENSE`, `CONTRIBUTING.md`, `CHANGELOG.md`, `SECURITY.md`, `packfs.pc.in`, `.gitea/workflows/ci.yml` — this project is hosted on Gitea, not GitHub), alongside the frozen `concept.md`, this file, and `POSTMORTEM.md` (every real bug and environment issue found during development, how each was actually diagnosed, the final fix, and the regression-prevention artifact for each — read it before re-investigating something that might already be answered there, and add to it rather than letting a future finding go unrecorded). Every file under `src/` and `include/` carries an `SPDX-License-Identifier: MIT` tag; `include/packfs.h`'s `PACKFS_VERSION_*` macros are the single source of truth for the project's version — `pfs_version()` (runtime) and `packfs.pc` (generated by `make install`) are both derived from them, never maintained separately.
## Build, test, and lint commands
+709
View File
@@ -0,0 +1,709 @@
# PackFS development postmortem
This document is a permanent record of every real issue found during this
project's development from the initial O(n²) file-index finding through the
`c414a27` CI fix — what happened, how it was actually diagnosed (including
false starts and things that looked like fixes but weren't), the final fix,
how it was verified, and what permanent artifact (a test, a CI change, a
documentation update) now prevents it from recurring silently. It exists
because several of the issues below were found only by *re-checking* a claim
that had already been asserted as true — the purpose of writing this down is
to make that re-checking unnecessary next time: read this first, not the git
log, to find out whether something below already covers the question at
hand.
Every finding here follows the same shape this project's other documents
already use (`CLAUDE.md`'s "Documentation standard"): a claim is not treated
as verified until it has been reproduced directly, not merely reasoned
about, and a revision extends the record rather than silently overwriting an
earlier, wrong belief. Several entries below are explicitly about a *test*
or a *fix* being wrong on its first attempt — those are kept, not deleted,
because the wrong first attempt and why it was wrong is exactly the
information that prevents the same mistake twice.
Commit hashes below refer to `master` in this repository
(`retoor.molodetz.nl/retoor/packfs`).
## Contents
1. [Performance findings](#1-performance-findings)
2. [Environment and tooling limitations](#2-environment-and-tooling-limitations)
3. [Project professionalization](#3-project-professionalization)
4. [Git identity correction](#4-git-identity-correction)
5. [Data-integrity fault injection](#5-data-integrity-fault-injection)
6. [CI as a second, independent reviewer](#6-ci-as-a-second-independent-reviewer)
7. [Distilled lessons](#7-distilled-lessons)
---
## 1. Performance findings
### 1.1 File index O(n²) in bulk sequential structural writes (`64283d5`)
**Symptom.** An early `make bench` run showed `mem`/`dir` bulk sequential
`create`/`unlink`/`mkdir` running 9–22x *slower* than the raw host
filesystem — the opposite of what an in-memory, in-process VFS should show.
**Diagnosis.** `UpperSnapshot` (`src/upper.c`) was a flat, sorted array;
every structural write copied the entire array before publishing the next
immutable snapshot (Section 5.3's single-writer model). At N=20,000 entries,
each `create` was doing an O(N) copy, making N sequential creates O(N²)
total. `concept.md` Section 5.3 had already named the exact trigger for
reconsidering this design ("a persistent (structurally shared) tree
structure is not required until this assumption is empirically violated")
— this was that violation, measured rather than hypothesized.
**Fix.** Rewrote the index as a persistent treap (Seidel & Aragon 1996;
Liljenzin arXiv:1301.3388 for the persistent-treap-as-MVCC-snapshot
technique specifically) — a structural write now touches only the O(log n)
nodes on the path to the change, sharing every other node (refcounted) with
the snapshot it was built from.
**Verification.** `mkdir`-at-N=4,000-vs-`create`-at-N=20,000 (same
mechanism, 5x the N) showed a 7.15x ratio post-fix versus 25–28.4x pre-fix —
O(n) predicts 5.0x, O(n log n) predicts 5.97x, the old O(n²) regime
predicted and measured 25–28.4x. This is the complexity-*class*
confirmation, not just "it got faster" (a constant-factor optimization could
also produce a speedup without changing the class). `mem` create/unlink went
from 9.5–22x slower than raw fs to 15–27x *faster*.
**Regression prevention.** `tests/test_index_stress.c` (thousands of
randomized, non-sequential creates/deletes/renames, cross-checked against an
independent reference model — not just "the benchmark ran fast without
crashing"). `BENCH.md`'s "Resolution" section carries the full before/after
table and this exact complexity-class methodology, reusable for any future
index change.
### 1.2 `pack_write` compaction dedup: O(n²) plus a latent correctness bug (`684c6d9`)
**Symptom.** None initially — this was found by deliberately auditing the
codebase for *other* instances of the O(n²) shape §1.1 had just fixed, not
by a benchmark regression.
**Diagnosis, first pass (nearly missed).** `bench/bench.c`'s own compaction
benchmark (category 8) never showed a problem, because its test files are
all byte-identical — every entry's linear duplicate-scan matched on the very
first comparison, which is the scan's *best* case, not its worst case. This
was the actual trap: a benchmark whose own test data accidentally happens to
avoid an algorithm's worst case will report a clean result for a genuinely
quadratic function. Re-measuring with unique per-file content instead
(calling `pack_write` directly, bypassing the journal/fsync path so the
measurement isn't confounded by this sandbox's separately-documented slow
`fsync` — see §2.2) surfaced it: 5,000/20,000/80,000 unique entries took
0.0138s/0.1114s/1.7389s, and the 20,000→80,000 (4x N) step showed 15.61x,
matching O(n²)'s 16x prediction.
**A second, independent bug found in the same code while reading it to fix
the first.** The dedup check compared only `(hash, size)` before declaring
two entries duplicates and sharing their `data_off` — never the actual
bytes. `src/hash.c`'s FNV-1a64 is explicitly documented as non-cryptographic
and not collision-resistant. Two different-content entries whose
`(hash, size)` happened to collide would have been silently merged,
corrupting one of them. Never observed in practice (no test's data happened
to collide), but true of the code as written — the kind of bug that stays
latent until it doesn't.
**Fix.** Replaced the linear scan with an open-addressing hash table
(`DedupSlot`, load factor 1/2, linear probing) *plus* an actual `memcmp`
against the candidate's stored data before ever reusing a `data_off` — a
hash match is now only ever a candidate, never accepted as proof of
equality. This closes both the complexity issue and the correctness gap
with one change, since the fix required for one directly implies the fix
for the other (the hash table alone, without `memcmp`, would still have
the collision bug; the `memcmp` alone, without a hash table, would still be
O(n²)).
**Verification.** Post-fix, the same methodology: 80,000 unique entries in
0.044s (39.6x faster), 20,000→80,000 ratio dropped to 3.35x (O(n) predicts
4x — close, and nowhere near 16x).
**Regression prevention.** `tests/test_pack_overlay.c` gained a white-box
assertion (`#include "internal.h"`) that `/a.txt` and `/dup.txt` (identical
content) share one `data_off` while `/b.txt` (different content) shares
neither's — this catches *both* "dedup stopped happening" and "dedup
over-matched," which a single boolean pass/fail on the benchmark could not
distinguish. `tests/test_pack_write_perf.c` is a standing regression
tripwire: 10,000 unique entries under a generous time bound, run as part of
`make test`, so a future accidental reversion to a linear scan fails loudly
and immediately rather than being rediscovered by a future audit.
### 1.3 Mount table O(n²) — confirmed, deliberately not fixed (`684c6d9`)
**Symptom.** None from user-facing behavior; found by extending the same
audit that found §1.2, this time to `src/vfs.c`'s mount table.
**Diagnosis.** `MountSnapshot` uses the identical full-array-copy-per-write
pattern the file index used to (§1.1) — confirmed via a new `bench/bench.c`
category (500/2,000/8,000 mounts): both 4x-N steps showed 15–20x, matching
O(n²)'s 16x prediction, for `mount`, `resolve`, *and* `unmount` alike
(`resolve` being O(n²) too was itself a finding — longest-prefix-match
dispatch is a linear scan of the mount table per call, O(n) per call, O(n²)
across N calls).
**Decision: not fixed.** Unlike the file index, mount points are created by
calls written into a program's own source code — bounded by how many lines
of "mount this backend at this path" a person or build script is willing to
write, not by user data or workload size. `concept.md` Section 5.3's own
stated trigger for needing a persistent structure ("not required until this
assumption is empirically violated") has not been violated here the way it
was for the file index, and converting the mount table anyway would add
real, permanent complexity for a case that doesn't occur in practice.
**Regression prevention.** `BENCH.md`'s "Finding: mount table scaling"
section and `CLAUDE.md`'s "Known performance characteristics" record the
measured numbers and the reasoning, explicitly labeled "NOT FIXED, by
deliberate decision" — so a future reader sees a considered decision, not
an unexamined gap, and doesn't need to re-derive whether this is worth
fixing from scratch.
---
## 2. Environment and tooling limitations
### 2.1 ThreadSanitizer cannot run in this sandbox — confirmed in two independent environments
**Symptom.** Every `-fsanitize=thread` build fails identically:
`FATAL: ThreadSanitizer: unexpected memory mapping`.
**Diagnosis.** TSan needs `personality(ADDR_NO_RANDOMIZE)` to disable ASLR
for itself before it can set up its shadow-memory layout. Confirmed via a
direct raw syscall (Python `ctypes`, `libc.personality(0x0040000)`) that
this returns `-1`/`errno=1` (`EPERM`) — a hard, syscall-level refusal, not a
PackFS-specific symptom (a trivial, unrelated two-thread pthread program
fails identically).
**Attempts that did *not* fix it, tried and ruled out rather than assumed
unhelpful:**
- `setarch $(uname -m) -R ./binary` — `setarch` just calls the same
`personality()` syscall internally; fails identically
(`Operation not permitted`).
- Compiling `-no-pie -fno-pie` — theorized that a non-PIE binary's fixed
load address might sidestep the need for ASLR-disabling, since ASLR
mostly affects PIE base-address randomization. Tested directly: still
fails identically. (TSan's shadow-memory requirement is about the whole
process's mmap layout — heap, libraries, stack — not just the main
executable's own base address, so this was never going to work; worth
recording precisely *why* it doesn't, so it isn't tried again on the
same mistaken theory.)
- Searching `TSAN_OPTIONS=help=1` for a flag that relaxes the startup
memory-mapping check — none exists.
- Checking for a privilege-escalation path: `/proc/self/status` shows
`CapEff` all-zero (no effective capabilities) under an active seccomp
filter (`Seccomp_filters: 1`) that *also* blocks `unshare --user`
(`Operation not permitted`) — ruling out running TSan inside a nested,
less-restricted namespace.
- Checking whether a genuinely different infrastructure provider has the
same restriction, via a dedicated remote-cloud-sandbox agent (not just
re-testing the same local machine): identical result —
`personality()` returns `EPERM`, a trivial pthread program fails TSan
identically, and all of this project's own test binaries fail with the
same `FATAL: ThreadSanitizer: unexpected memory mapping` signature.
**Conclusion.** This is a categorical, syscall-level restriction with no
userspace workaround available, confirmed in two independent sandboxed
environments, not a configuration this project has simply failed to find
yet. **As of this writing, TSan has not completed a single run in any
environment this project has actually been built in** — this is stated
plainly in `README.md`, `CONTRIBUTING.md`, and `CLAUDE.md` rather than left
implied by CI showing green (see §2.2's segfault variant, and §6.2, for why
a green TSan CI step specifically is *not* evidence TSan ran).
**Regression prevention.** `CONTRIBUTING.md`'s sanitizer-testing section and
`CLAUDE.md`'s equivalent paragraph state this explicitly, including the
exact confirming tests, so a future session doesn't have to re-derive "is
this fixable" from scratch — it's already been checked, thoroughly, and the
answer is recorded along with the checking.
### 2.2 ASan/UBSan sandbox-startup flake, two variants
**Variant A: clean, bounded.** A sanitizer-built binary occasionally
(non-deterministically) fails to start, printing `AddressSanitizer:
DEADLYSIGNAL` once and exiting nonzero. Confirmed as a sandbox race, not a
PackFS bug, by it hitting *different, unrelated* binaries across repeated
runs (in one session: `test_dir` and `test_mem`; in another:
`test_pack_overlay`; in another: nothing at all), with every affected binary
passing cleanly on a repeat run.
**Variant B: unbounded.** The same underlying race can instead manifest as
an *unbounded repeating loop* of the same `AddressSanitizer:DEADLYSIGNAL`
line — observed directly consuming CPU/memory for minutes and, in one
extreme case during this project's own CI-script debugging (§6.2), writing
enough output within a 30-second `timeout` window to reproduce as a
264MB+ log.
**Mitigation, not elimination:** always wrap sanitizer-build test runs in
`timeout` (bounds variant B's wall-clock cost); treat a bare
`DEADLYSIGNAL` exit as inconclusive and re-run; only treat it as a real
finding if the output actually contains `ERROR: AddressSanitizer` or
`runtime error:`. `tests/test_crash_consistency.c` hits this flake
noticeably more often than the rest of the suite (it forks 60+ subprocesses
per run — each fork is an independent chance to hit the same startup race)
— this is expected, not a sign specific to that test, and is called out in
`CONTRIBUTING.md` so a future reader doesn't misdiagnose it as a
regression in that file.
**Regression prevention.** `CONTRIBUTING.md` documents both variants, the
required `timeout` mitigation, and the exact string check that separates a
real finding from this flake. §6.2 covers a further, serious follow-on
mistake made while automating this exact mitigation in CI — see there for
why "wrap it in timeout" was necessary but not, on its own, sufficient.
---
## 3. Project professionalization
Prompted by "do literally everything for this project to be taken
seriously." A survey of what a serious C library project needs, each
verified rather than assumed present:
- **`SPDX-License-Identifier: MIT`** added to every file under `src/` and
`include/` (`include/packfs.h` already had one; the `src/*.c` files and
`src/internal.h` did not).
- **Version API**: `PACKFS_VERSION_MAJOR`/`MINOR`/`PATCH`/`STRING` in
`include/packfs.h`, a runtime `pfs_version()` (`src/vfs.c`, next to
`vfs_new`/`vfs_free`). Verified with a new assertion in
`tests/test_mem.c` that the compile-time macro and the runtime function
never disagree — a version API that can silently drift between its two
forms is worse than not having one.
- **`packfs.pc`** (pkg-config), generated by `make install` from a new
`packfs.pc.in`, with its `Version:` field derived from
`PACKFS_VERSION_STRING` via a Makefile-level `grep`/`sed` rather than
hand-maintained separately. Verified end-to-end with a scratch
`make install PREFIX=...` followed by an actual
`pkg-config --cflags --libs packfs` call and `make uninstall` — not just
by reading the Makefile rule.
- **`SECURITY.md`**: states precisely what this project's containment and
pack-integrity code actually claims as a security boundary (`concept.md`
Section 6/7) versus what it explicitly does not (unenforced `mode`, no
cross-process concurrency) — including, after a later audit (§5), that
the pack integrity checksum (FNV-1a64) is non-cryptographic and not
tamper-evident against a deliberate adversary, a fact that was already
documented in the context of §1.2's dedup bug but had never been
explicitly connected to its *other* use, load-time integrity validation,
where it matters more.
- **`CHANGELOG.md`** (Keep a Changelog format), built from the real git
history — corrected once already (see the `a914330` commit) when a prior
commit landed *after* the `v0.1.0` tag without ever getting its own
entry, leaving the changelog stale the moment it happened. The fix:
recognize that "I tagged a release" is not the same event as "I stopped
needing to update the changelog," and check for this specifically after
any tag.
- **Considered and explicitly declined**: a `CODE_OF_CONDUCT.md`, per the
user's own choice when asked — recorded here so a future session doesn't
re-propose it as an oversight.
- **Gitea, not GitHub**: on explicit direction, `.github/workflows/ci.yml`
moved to `.gitea/workflows/ci.yml` (Gitea Actions' convention), and every
doc that assumed GitHub-specific features was corrected — most notably
`SECURITY.md`, which had claimed "private security advisories" would be
available once hosted, a GitHub feature this project's actual host
(Gitea) was never confirmed to have; reporting is by direct email only.
`README.md`'s claim that the suite is "regularly run under
ThreadSanitizer" was also corrected at this point to reflect §2.1's
actual, confirmed status, rather than left as an aspirational statement
a reader could mistake for a tested one.
**Regression prevention.** All of the above are either self-verifying
(the version API test, the pkg-config round-trip) or are stated as
documentation with an explicit "verified by X" attached, per this
project's own documentation standard — the standard itself is the
regression-prevention mechanism here: a claim without a verification note
is treated as suspect on sight.
---
## 4. Git identity correction
**Symptom.** None from the code; a direct user request ("My name is
literary nowhere to find in the whole log anymore, not even in met a with
grep?") revealed the actual bug: every commit's author/committer showed the
user's real name, because commits had been made under whatever local git
config was already set on the machine, without ever asking what identity
this project should use — despite the user having already established
`SECURITY.md`'s contact as "retoor."
**Fix, first pass.** `git filter-branch --env-filter` rewrote every
commit's author *and* committer across all 11 commits at the time, since
nothing had been pushed to any remote yet (confirmed via `git remote -v`
returning empty first) — making this a safe, local-only rewrite, not a
history rewrite against shared state.
**A real gap found only by verifying the "fixed" state exhaustively,
not by re-checking `git log`.** `git log --format='%an <%ae>'` showed only
the corrected identity — but a full object-database sweep (`git
cat-file --batch-all-objects`, dumping and `grep`-ing every commit, tree,
blob, *and tag* object, not just what `git log` surfaces) found the
annotated `v0.1.0` tag object's own `tagger` field still showed the real
name. `git filter-branch`'s tag-rewriting step recreates the tag object
pointing at the new commit hash but does not apply the `--env-filter` to
the tag object's own tagger line — a genuinely separate object type with
its own separate metadata field, easy to miss if verification stops at
"does `git log` look right."
**Fix, second pass.** Deleted and recreated the `v0.1.0` tag (with the
local git identity already corrected by that point), producing a fresh
tag object with the correct tagger.
**Verification, exhaustive rather than spot-checked:** dumped and grepped
the content of all ~126 packed objects (case-insensitively, for the name,
the surname alone, and the email) — zero matches; ran
`git fsck --unreachable --dangling` after `gc --prune=now` — nothing left
over from the rewrite; checked `.git/config`, `.git/packed-refs`, both
reflog files' actual content (not just `git reflog show`), notes, and
stash — all clean; raw byte-level `grep -a -r -i` across the entire
`.git` directory — zero matches.
**Regression prevention.** None needed as an ongoing mechanism (this was a
one-time correction, not a recurring class of bug) — but the *methodology*
is worth keeping: `git log` alone is not a complete verification of "is
this identity anywhere in this repository," because it doesn't surface tag
objects, reflogs, or dangling objects. A full object-database sweep is the
actual bar for "confirmed gone," and is cheap enough (a few seconds for a
repository this size) to just do rather than trust a partial check.
---
## 5. Data-integrity fault injection
Prompted by a direct question ("How safe is packfs to use for data
integrity?") that got an honest answer identifying two real, then-open
gaps: no fault-injection testing of the documented crash-safety claims, and
TSan having never actually verified the concurrency-safety claims (§2.1).
The user's follow-up ("Do whatever you need to do to make you trust it")
turned this from a documentation exercise into real engineering work.
### 5.1 `tests/test_crash_consistency.c`: real `fork()`+`SIGKILL` fault injection (`e41bd23`)
**What it does.** Forks a real child process, lets it run for an
empirically-calibrated delay (measured via throwaway scripts against this
build, not guessed — e.g. ~6ms per individually-journaled write, ~12ms for
a 4,000-entry/2KB-payload compaction), then `SIGKILL`s it and checks what a
fresh reopen recovers. Two scenarios: a burst of individually-journaled
writes (25 trials), and a compaction (`vfs_sync`) call (40 trials).
**A real bug in the test itself, found before trusting its results.** The
first draft's compaction scenario reported 128 failures. Every one was a
bug in the test's own oracle, not in PackFS: it checked "is extra write *i*
present after the crash" without distinguishing "the child was killed
before it ever attempted write *i*" (expected, not a bug) from "write *i*
completed and was then lost" (would be a real bug) — `SIGKILL` cannot be
caught, so the child has no way to report its own progress through the
normal API once it's been killed. Fixed by adding an independent progress
side-channel: a plain POSIX `write()`+`fsync()` on a dedicated file,
entirely outside packfs, recording "N extras durably completed" after each
one — giving the test's own verification step ground truth for how far the
child actually got, independent of (and not trusting) the thing being
tested.
**Result, after the test's own bug was fixed.** 20–22/25 burst trials and
40/40 compaction trials land a genuine interruption (`WIFSIGNALED`, not
`WIFEXITED`) per run; zero corruption or gaps found across dozens of runs,
including under ASan/UBSan.
**Regression prevention.** This test itself, run as part of `make test` and
CI on every push.
### 5.2 Silent journal-write failure — a real, previously-unknown data-loss bug (`153d44e`)
**How it was found.** Not by a test — by reading `src/overlay.c`'s journal
code while building a fault-injection test for the "disk full mid-write"
case §5.1 didn't cover.
**The bug.** `journal_append_record` and everything that called it
(`journal_append_put`/`_delete`/`_mkdir`, `journal_put_current`) were
`void`, and none of `journal_append_record`'s `fwrite`/`fflush`/`fsync`
calls had their return values checked. A real write failure (disk full,
quota, an I/O error) was silently reported as success by
`vfs_write`/`vfs_mkdir`/`vfs_unlink`/`vfs_rename` on an overlay-backed
file — directly contradicting Section 4.4's premise that a successful
journal append means the write is durable.
**Fix.** Made the whole call chain return and propagate success/failure;
the affected `vfs_*` call now returns `VFS_ERR_IO` instead of silently
succeeding — while leaving the already-applied in-memory change as-is
(readers in the same process still see it), the same asymmetry a real
`write()`-succeeds-but-a-later-`fsync()`-fails has. There is no way to
"undo" the in-memory update, and the return value's job is to report
durability, not roll back visible state.
**A second, deeper bug found only by reproducing the first fix's edge
case, not by reasoning about it.** Fixing the silent-failure bug alone was
not enough: a partial write leaves a torn record sitting in the middle of
the journal file, and `journal_replay` correctly stops at the first record
it can't fully read (Section 4.3, by design). A torn record left behind by
a *failed* write therefore poisons every record appended *after* it too —
including ones that themselves complete successfully later. Reproduced
directly before fixing: a forced-failed write followed immediately by a
genuinely successful one was unrecoverable after reopening — the good
record existed in the file, replay just never got past the torn one sitting
in front of it.
**Fix.** Roll the journal file back to its exact pre-record byte length
(`ftell` captured before any of the record's bytes are written, `ftruncate`
on failure) whenever a record fails partway, keeping "the journal on disk
is valid up to EOF" true even when an individual write fails.
**A third bug, found by inspection while in the same code.**
`journal_put_current` used to pass a `NULL` buffer into
`journal_append_record`'s `memcpy` of a nonzero size when `malloc(size)`
failed — an OOM-triggered NULL-pointer dereference. Closed with an
explicit `if (size && !buf) return -1;` guard. Not test-triggered
(reliably forcing `malloc()` failure in a portable, safe way isn't
practical here) — verified by code inspection and stated as such, not
claimed as tested.
**Regression prevention.** `tests/test_journal_failure.c`, which forces a
*real* write failure via `RLIMIT_FSIZE` + ignoring `SIGXFSZ` (so `write()`
returns `EFBIG` instead of killing the process) rather than a mock, and
checks all three properties in one place: the failure is reported (not
swallowed), a fresh reopen does not see the torn record as if valid, and a
later genuinely-successful write on the same overlay session survives
reopening (the exact scenario that was broken before the rollback fix).
---
## 6. CI as a second, independent reviewer
Two real bugs were found not by local testing but by Gitea CI running the
exact same code under a genuinely different invocation — worth recording
as its own category, because both were invisible locally for structural
reasons, not bad luck.
### 6.1 A real memory leak, masked by a local sanitizer habit (`4aad7d6`)
**What CI reported.** `LeakSanitizer: detected memory leaks` — 256 bytes
across 4 allocations, from `vfs_new`/`vfs_unmount` call sites.
**Why local testing never caught it.** Local ASan/UBSan verification had
been using `ASAN_OPTIONS=detect_leaks=0`. The reasoning behind that
override was sound on its own terms: a `SIGKILL`ed forked child (§5.1)
never runs its own exit-time leak check, so its allocations were never the
actual concern. The mistake was applying it to the *entire* test binary's
run, not just scoping it to the child's own allocations — this also
suppressed LeakSanitizer for the *parent* process's own code, which is
exactly where the real bug was sitting.
**The actual bug.** `tests/test_journal_failure.c`'s two "reopen after the
failure, verify recovery" blocks called
`vfs_unmount`/`backend_free`/`backend_free` inside the `if (ov2)` branch
(the normal, expected path, always taken since the code's own `CHECK`
asserts `ov2 != NULL`) but `vfs_free(v2)` only on the `else` branch, which
in practice is never reached.
**Fix.** Moved `vfs_free(v2)` to run unconditionally after the `if`, in
both blocks.
**Verification.** Reproduced the leak first — dropped the
`detect_leaks=0` override locally, matching CI's actual invocation exactly,
*before* touching any code — then confirmed the fix the same way: all 8
test binaries clean under ASan/UBSan with leak detection *on*, run
multiple times.
**Regression prevention.** `CONTRIBUTING.md` now states this gap
explicitly: do not add `detect_leaks=0` (or any other blanket
sanitizer-weakening option) to a local verification habit without it also
being in `.gitea/workflows/ci.yml` — if local and CI check different
things, a real finding can pass locally and only surface once it reaches
CI, which is exactly what happened here.
### 6.2 The `set -e` bug: CI's own script was the actual root cause of a reported failure (`c414a27`)
This is the most involved finding in this document and the one most worth
reading in full before touching `.gitea/workflows/ci.yml` again.
**What was reported.** Gitea CI's "Build and run under ThreadSanitizer"
step failing with `exitcode '66': failure`, after the build and test-suite
steps passed. 66 is TSan's own raw exit code for the `FATAL:
ThreadSanitizer: unexpected memory mapping` failure (§2.1) — the step's own
classification logic (added specifically to *not* fail the build over that
known, environment-caused failure) was supposed to catch this and turn it
into a warning, not a build failure.
**Root cause, confirmed by direct reproduction, not theorized.** The
step's script assigned captured output via a bare, unwrapped
`OUT=$("./binary" 2>&1)`. Gitea Actions' `run:` steps execute under `bash
--noprofile --norc -eo pipefail {0}` — `set -e` is on by default. Under
`-e`, a command substitution's own nonzero exit status counts as that
simple command's failure, which aborts the whole script *immediately* —
*before* the very next line, even one that only reads `$?`, ever runs.
Reproduced directly with a two-line script:
```sh
set -e
OUT=$(false)
RC=$?
echo "after" # never printed
```
This meant the classification logic a few lines further down the real
script never executed at all: the first TSan binary to hit the FATAL/
exit-66 case (all of them, on this runner) killed the step outright with
that raw exit code, exactly the outcome the classification logic existed
to prevent.
**Fix.** Wrap the invocation in `if cmd; then RC=0; else RC=$?; fi` —
bash's `-e` rules specifically exempt a command used as an `if` condition
from triggering an abort. Reproduced the fix working, the same way:
```sh
set -e
if OUT=$(false); then RC=0; else RC=$?; fi
echo "after, RC=$RC" # prints: after, RC=1
```
**A follow-on bug, found only while verifying the fix under real
conditions, not assumed absent.** With the `-e` bug fixed, re-running the
corrected script repeatedly to verify it eventually hit §2.2 Variant B (the
flake's unbounded-loop form) — and when it did, the classification logic
misbehaved: an error line reported `(exit 0)`, which should have been
impossible. Root cause: capturing hundreds of megabytes of repeated
`AddressSanitizer:DEADLYSIGNAL` lines into a bash variable via
`OUT=$(cmd)` is not just slow — at that scale, the shell's own
string-handling stopped behaving reliably enough to trust the
classification built on top of it. **Fixed by redirecting output straight
to a file instead of a shell variable, and reading back only a bounded
64 KiB prefix** for both classification and logging (chosen generously —
this project's own real ASan/UBSan failure reports have historically been
a few dozen lines, nowhere near 64 KiB, while the flake's worst case was
hundreds of megabytes). This was applied to *both* the ASan/UBSan and TSan
steps, since both had the same fragility once genuinely stressed.
**A mistake made while applying that exact fix, caught by testing it, not
by review.** The first attempt at the file-redirect fix wrote
`timeout 30 "./binary" > "$LOG" 2>&1` as a bare statement — reintroducing
the *exact same* `-e`-abort bug the whole exercise started from, just in a
new shape (redirecting to a file doesn't change whether the command itself
is subject to `-e`'s abort rule; only wrapping it in a tested context
does). Caught immediately by re-running the same two-line reproduction
technique against the new form before trusting it, not by inspection:
```sh
set -e
timeout 5 false > /tmp/out.log 2>&1
RC=$?
echo "after" # never printed -- same bug, new location
```
Fixed the same way as the first bug: `if timeout 30 "./binary" > "$LOG"
2>&1; then RC=0; else RC=$?; fi`.
**A second, previously undocumented flake variant, discovered while
repeatedly reproducing the above.** TSan's broken startup on this runner
does not always print the clean `FATAL: ThreadSanitizer: unexpected memory
mapping` message — it sometimes segfaults outright instead (`timeout`
reports "the monitored command dumped core"). Confirmed this is the *same*
environmental cause, not a bug in any specific test file, by watching it
hit three different, unrelated binaries across repeated full-suite runs
(`test_journal_failure` in one run; `test_crash_consistency` and `test_dir`
together in another) — a real bug in one file's own code would not migrate
between files at random like that. The TSan step's classification now also
recognizes this variant: a log containing only `timeout`'s own "dumped
core" notice and nothing else (no program output, no real
`WARNING`/`SUMMARY: ThreadSanitizer:` race report) is treated the same as
the clean FATAL-message case.
**A retry-budget gap, found by observing real failures during this same
verification work, not estimated in advance.** The ASan/UBSan step retries
a binary up to a fixed count before giving up; with a budget of 3, a real
run during this exact debugging session hit the DEADLYSIGNAL flake three
times in a row on the same binary purely by chance, exhausting the budget
and failing the step even though every individual attempt was correctly
classified as the known flake, not a real finding. This sandbox's actual
flake rate is evidently higher in practice than the "roughly 1 in 5–10"
`CONTRIBUTING.md` documents elsewhere. Fixed by raising the budget to 5,
which reduces (does not eliminate — this is a probabilistic mitigation,
not a fix for the underlying flake) the chance of exhausting it on bad luck
alone.
**Verification, cumulative, all under `bash -eo pipefail` locally
(matching Gitea Actions' actual shell invocation) rather than assumed
correct from reading the diff:**
- The original `-e` bug: reproduced and fixed, confirmed with the two-line
repro above.
- The TSan step: re-run 10 full-suite times (80 individual binary
executions) after the fixes above, with both flake variants recurring
naturally and both correctly classified as warnings, zero false
failures.
- The ASan/UBSan step: re-run 10 full-suite times with the corrected
bounded-file logic and the 5-attempt budget, zero false failures.
- A separate, unrelated `-Wunused-result` warning on an intentionally-
ignored `write()` return value in `tests/test_crash_consistency.c`
(§5.1's progress side-channel) was *also* flagged by this same CI run.
The `(void)` cast used to silence it built warning-free locally but
still warned on the Gitea runner's gcc — reproduced clean locally with
the exact same compiler flags first, confirming this is a real
toolchain version/configuration difference between the two machines,
not a local misconfiguration on either side. `(void)`-cast suppression
of `warn_unused_result` is documented as unreliable across gcc
configurations for exactly this reason; fixed with an actual
conditional branch on the return value (`if (write(...) < 0) { }`)
instead, which every gcc/clang version this project has been built with
honors.
**Regression prevention.** `.gitea/workflows/ci.yml` itself now carries
inline comments at each fixed site explaining exactly what would break and
why, so a future edit to these scripts doesn't reintroduce the same class
of bug without at least being warned by the comment sitting right there.
This document is the second layer: read §6.2 in full before touching the
sanitizer steps' shell scripts again, and re-run the exact two-line
`set -e` reproduction technique above against any new command-substitution
pattern before trusting it — that technique, cheap and fast, is what
caught every bug in this section, including the one introduced while fixing
the previous one.
---
## 7. Distilled lessons
These are the patterns that actually caught something above, stated once
here rather than only implicitly in each entry, so they can be applied to
a *new* problem, not just recognized in hindsight on this one:
1. **A benchmark or test's own data can accidentally avoid the exact case
being measured.** §1.2 was invisible because the benchmark's identical
test-file content made a linear scan's worst case never happen. When
auditing for a known bug shape elsewhere, deliberately construct the
adversarial input, don't just re-run the existing benchmark and trust a
clean result.
2. **A fault-injection test's own oracle can be wrong, and has to be
checked with the same rigor as the code it's testing.** §5.1's first
128 "failures" were a bug in the test. The fix (an independent
ground-truth side-channel, entirely outside the system under test) is
the general pattern: when the thing being tested is also the thing
reporting whether it worked, the report can't be trusted.
3. **Fixing one bug in a code path can leave a nearby, related bug
unfixed, discoverable only by reproducing the fix's own edge cases.**
§5.2's torn-record-poisoning-later-records bug was found only by
confirming the *first* fix actually solved the whole problem, not by
inspecting the diff and assuming it did.
4. **A local verification shortcut that diverges from what CI actually
runs can hide a real bug indefinitely.** §6.1: `detect_leaks=0` was
reasonable for one specific reason, applied too broadly, and CI (which
didn't share the shortcut) caught what dozens of local runs couldn't.
5. **`bash -e` does not do what it looks like it does around command
substitution.** §6.2, hit twice in the same debugging session (once as
the original bug, once as a mistake made while fixing it): `OUT=$(cmd)`
or `cmd > file` as a bare statement aborts the whole script on failure,
even one line before a line that reads `$?`. The fix is always
`if cmd; then ...; else RC=$?; fi`. Test this specific pattern with the
two-line reproduction in §6.2 before trusting any new script that
relies on inspecting a command's exit code under `set -e`.
6. **Capturing genuinely unbounded output into a shell variable is not
just a performance concern — it can make downstream logic behave
incorrectly at scale**, not just slowly. §6.2's second bug. Redirect to
a file and bound what's ever read back.
7. **A crash's signature is not necessarily unique — the same underlying
cause can manifest in more than one way, and the way to tell "same
cause, different symptom" from "different bug entirely" is to check
whether it's tied to specific code or migrates across unrelated
files/binaries at random.** §6.2's segfault variant of the already-known
TSan-can't-start issue was confirmed this way, not assumed.
8. **A compiler-warning suppression that works locally is not guaranteed
to work on a different toolchain build, even nominally "the same
compiler."** §6.2's `(void)`-cast case. Prefer suppressions that are
specified to work (an actual branch on the value) over idioms that
merely happen to compile clean once.
9. **Verifying "is this identity/string anywhere in this repository" means
more than `git log`.** §4: tag objects, reflogs, and dangling objects
all needed their own explicit check; a full object-database sweep is
cheap enough to just do.
10. **When a claim can be reproduced directly, reproduce it — don't reason
about whether it's true.** Every fix in this document was confirmed by
actually re-running the failing case under the same conditions that
produced the failure (the same shell mode, the same compiler flags,
the same sandbox), not by reading the diff and concluding it must now
be correct. This is the one pattern underlying all the others above.
+5 -1
View File
@@ -321,7 +321,11 @@ security boundary, and how to report a vulnerability.
See [`CONTRIBUTING.md`](CONTRIBUTING.md). Read `concept.md` and `CLAUDE.md`
first — they are the project's actual specification and its enforced
documentation standard, respectively, and every design decision in the code
traces back to one of them.
traces back to one of them. [`POSTMORTEM.md`](POSTMORTEM.md) is a detailed,
permanent record of every real bug and environment issue found during this
project's development — what happened, how it was diagnosed, the fix, and
the regression-prevention artifact for each. Check it before re-diagnosing
something that might already be answered there.
## License