CI / build-and-test (push) Failing after 33s
Prompted by "fix literally everything still open" after the previous
session's data-integrity work. Went through each open item in turn:
1. TSan: tried a genuinely different execution environment (a remote
cloud sandbox, via a dedicated agent) rather than re-stating the local
sandbox's limitation. Result: identical block there too --
personality(ADDR_NO_RANDOMIZE) returns EPERM, a trivial pthread
program fails TSan identically, and all 6 PackFS test binaries fail
with the same FATAL: ThreadSanitizer: unexpected memory mapping
signature. This is now confirmed in two independent environments, not
one -- strong evidence it's a real infrastructure restriction, not a
one-off fluke worth chasing further with the tools available here.
2. While investigating the "disk-full mid-write" gap flagged as untested
last session, found two real, previously-unknown bugs by reading the
journal code (not by a test catching them unprompted):
- journal_append_record and everything that called it were void, and
none of the fwrite/fflush/fsync calls inside had their return values
checked. A real write failure (disk full, quota, I/O error) was
silently reported as success to vfs_write/vfs_mkdir/vfs_unlink/
vfs_rename -- directly contradicting Section 4.4's premise that
success means durable.
- Fixing that alone was not enough, confirmed by direct reproduction:
a partial write leaves a torn record in the journal, and
journal_replay correctly stops at the first record it can't fully
read (Section 4.3) -- which means every record appended *after* the
torn one, including ones that themselves wrote perfectly fine later,
became silently unreachable on reopen. Reproduced directly before
fixing: a forced-failed write followed by a genuinely successful one
was unrecoverable. Fixed by rolling the journal file back to its
exact pre-record length on any failed write.
Both closed in src/overlay.c (journal_append_record/_put/_delete/
_mkdir/journal_put_current now return and propagate success/failure;
overlay_write/_mkdir/_unlink/_rename return VFS_ERR_IO on a durability
failure without rolling back the already-applied in-memory change,
the same asymmetry a real write()-then-failed-fsync() has). Covered
permanently by the new tests/test_journal_failure.c, which forces a
real failure via RLIMIT_FSIZE + ignored SIGXFSZ, not a mock.
Also fixed in the same pass, found by inspection while touching this
code: journal_put_current used to pass a NULL buffer into a memcpy of
a nonzero size when malloc(size) failed (an OOM-triggered NULL-pointer
dereference) -- closed with an explicit allocation-failure check.
Not test-triggered (forcing malloc() failure portably isn't practical
here); verified by code inspection instead, stated as such rather than
claimed as tested.
3. The remaining "journal-truncation-specific crash window" gap from last
session was investigated, not silently dropped: reliably targeting
that narrow a window would need real concurrency (a second writer
thread racing the kill) for benefit the existing compaction-crash test
already gets probabilistically -- a poor trade, so left as a stated,
deliberate non-goal (CLAUDE.md) rather than built.
4. Cross-process contention is NOT addressed here and should not be read
as an oversight: it is concept.md's own explicit, permanent "not
implemented in v0" scope boundary (a specified-but-unbuilt LMDB-style
reader-table design), not a bug -- building it would be a large,
unrequested feature addition outside this session's actual scope.
Verified: clean make all + make test (all 8 binaries), make bench and
make demo still build and the demo runs correctly end to end, and a full
ASan/UBSan sweep of all 8 binaries with zero real findings (some retries
needed for the already-documented DEADLYSIGNAL flake, which
test_crash_consistency hits more often than other tests simply because it
forks 60+ subprocesses per run -- noted in CONTRIBUTING.md so this isn't
mistaken for a regression later).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqJpkdJ6Njnt1pw3CbghzB
137 lines
8.0 KiB
Markdown
137 lines
8.0 KiB
Markdown
# Contributing to PackFS
|
||
|
||
## Before changing anything
|
||
|
||
Read [`concept.md`](concept.md) in full. It is the project's specification,
|
||
not background reading — every backend, lock, and on-disk field in `src/`
|
||
exists because a section of `concept.md` requires it, and most functions'
|
||
comments cite the section they implement rather than re-explaining it.
|
||
`concept.md` is frozen (see [`CLAUDE.md`](CLAUDE.md)): if you believe the
|
||
design itself needs to change, that belongs in a new document that amends
|
||
or supersedes it, not in an edit to `concept.md`.
|
||
|
||
Then read [`CLAUDE.md`](CLAUDE.md), which states the load-bearing constraints
|
||
a change must respect (static linking, the write model, the concurrency
|
||
model, path containment, pack integrity) and the documentation register this
|
||
project is written in.
|
||
|
||
## Workflow
|
||
|
||
```sh
|
||
make test # must pass before any PR
|
||
cc ... -fsanitize=address,undefined # ASan/UBSan: see .gitea/workflows/ci.yml for exact flags
|
||
cc ... -fsanitize=thread # TSan, for anything touching src/upper.c, src/overlay.c, or src/vfs.c
|
||
```
|
||
|
||
Any change to the concurrency-sensitive files (`upper.c`, `overlay.c`,
|
||
`vfs.c`) must be run under ThreadSanitizer, not just the plain test suite —
|
||
a data race there is exactly the class of bug Section 5 of `concept.md`
|
||
exists to prevent, and the plain build will not surface it.
|
||
|
||
**Known environment limitation, stated plainly rather than glossed over:**
|
||
ThreadSanitizer cannot run at all in some sandboxed/containerized
|
||
development environments — including the one this project's own commit
|
||
history was largely developed in — because TSan requires disabling ASLR
|
||
for itself via `personality(ADDR_NO_RANDOMIZE)`, and some sandboxes block
|
||
that syscall outright (confirmed here: `personality()` returns `EPERM`,
|
||
and even a trivial unrelated pthread program fails identically with
|
||
`FATAL: ThreadSanitizer: unexpected memory mapping`, not just PackFS code).
|
||
If your environment can't run TSan, say so rather than silently skipping
|
||
it or claiming verification that didn't happen — ASan/UBSan still catch
|
||
real bugs (they found and fixed a genuine heap-use-after-free during this
|
||
project's development, see the `reclaim_gate` note in `internal.h`) but do
|
||
not do TSan's happens-before race analysis, so they are not a substitute
|
||
for it. CI (`.gitea/workflows/ci.yml`, Gitea Actions — this project is
|
||
hosted on Gitea, not GitHub) runs on this project's own self-hosted
|
||
runner. **Confirmed, not just anticipated: that runner cannot run TSan
|
||
either.** The first real CI run on it failed the ThreadSanitizer step with
|
||
the exact same signature described above (`FATAL: ThreadSanitizer:
|
||
unexpected memory mapping`), consistent with the runner's job containers
|
||
using a default seccomp profile that blocks `personality()` the same way
|
||
some local sandboxes do. The CI step now detects this specific failure
|
||
signature and does not fail the build over it (while still failing hard on
|
||
any *other* TSan outcome — a real race, a crash, anything without that
|
||
exact signature) — see the step's own comment in `ci.yml` for the
|
||
detection logic. This means **CI's ThreadSanitizer step passing is not
|
||
evidence that TSan actually ran** on a given push; it may just mean the
|
||
runner couldn't start it and the step correctly didn't treat that as a
|
||
failure. Treat a change as TSan-verified only once it has actually passed
|
||
on a machine confirmed able to run it (a local machine or container with
|
||
`personality(ADDR_NO_RANDOMIZE)` available), not merely because CI is
|
||
green.
|
||
|
||
**A second, separate environment quirk, also observed directly rather than
|
||
assumed:** in the same kind of sandboxed environment, an ASan/UBSan-built
|
||
test binary occasionally (non-deterministically, roughly 1 run in 5–10 in
|
||
this project's own experience) fails to start at all, printing
|
||
`AddressSanitizer:DEADLYSIGNAL` — and, in its worse form, printing that same
|
||
line in an unbounded loop rather than printing it once and exiting, which
|
||
can run for minutes and consume unbounded CPU/memory if left unattended.
|
||
This has been observed hitting different, unrelated test binaries from run
|
||
to run (in one session: `test_dir` and `test_mem`, in another: nothing at
|
||
all, in another: `test_pack_overlay`), which — together with the fact that
|
||
every one of those binaries passes cleanly on a repeat run — indicates a
|
||
sandbox startup race (plausibly in the same family as the ASLR/mapping
|
||
restrictions behind the TSan limitation above), not a bug in the binary
|
||
being tested. **`tests/test_crash_consistency.c` hits this flake noticeably
|
||
more often than the rest of the suite** (observed 2 failures in 5 runs,
|
||
versus roughly 1-in-5–10 elsewhere) — expected, not a sign anything is
|
||
wrong with that test specifically: it forks 60+ subprocesses per run
|
||
(one per fault-injection trial), and each fork is an independent chance to
|
||
hit the same startup race, so a test that forks this much will trip it
|
||
proportionally more often. Re-run it in isolation before treating a
|
||
failure there as a real finding, same as any other `DEADLYSIGNAL` exit. **Always wrap sanitizer-build test runs in `timeout` in such
|
||
an environment** (e.g. `timeout 30 ./build/san/test_name`) so a stuck run
|
||
fails loudly and bounded instead of hanging; treat a `DEADLYSIGNAL` exit as
|
||
inconclusive, re-run, and only treat the run as a real finding if the output
|
||
contains an actual `ERROR: AddressSanitizer` or `runtime error:` string —
|
||
`DEADLYSIGNAL` alone, with neither of those strings present, is this flake,
|
||
not a memory-safety bug in PackFS. As with the TSan limitation, the correct
|
||
response is to say this plainly and re-run until a clean pass is obtained
|
||
(or hand off to CI, which runs on an unrestricted VM where this has not been
|
||
observed), not to suppress or silently ignore a `DEADLYSIGNAL` exit.
|
||
|
||
A change to the index structure in `upper.c` (the `TreapNode`/`treap_*`/
|
||
`node_*` functions, `snapshot_upsert`, `snapshot_remove`, or the
|
||
`UpperSnapshot`/`UpperEntry` representation) should be run through
|
||
`make bench` before and after, not just `make test`: `BENCH.md` records a
|
||
real, measured O(n²) cost the flat array this index used to be had in bulk
|
||
sequential create/unlink, and the persistent treap that replaced it exists
|
||
specifically to avoid regressing back into it — a correctness-preserving
|
||
change to this code can still reintroduce that regression (e.g. an
|
||
implementation that silently degrades to a linked list under adversarial
|
||
insertion order) in a way `make test` alone cannot detect, only `make bench`
|
||
plus `tests/test_index_stress.c`'s randomized-order correctness check can.
|
||
|
||
## Scope
|
||
|
||
Changes that add functionality `concept.md` Section 10 lists as explicitly
|
||
out of scope for v0 (full POSIX semantics, enforced permissions/symlinks/hard
|
||
links, cross-process concurrency, content-defined chunking/delta
|
||
compression) should discuss the tradeoff with a maintainer first — those
|
||
exclusions were deliberate design decisions, not gaps waiting to be filled.
|
||
|
||
**`zip` and `tar` import/export backends will not be accepted, full stop —
|
||
not discussed, not behind a flag.** `concept.md` Section 11 recommends them,
|
||
but that recommendation is permanently superseded; see `CLAUDE.md`, "Project
|
||
decisions that supersede concept.md." This is a harder line than the v0
|
||
exclusions above, which are open to future discussion — this one is not.
|
||
|
||
## Static-linking constraint
|
||
|
||
`concept.md` Section 11.1 is a hard constraint: the core must build with
|
||
zero required third-party libraries and zero dynamic loading. In practice
|
||
this project has no optional-dependency boundary at all: `concept.md`
|
||
described one at the `zip`/`tar` backend for `miniz`/`libarchive`, but per
|
||
the decision above, that backend will never exist, so there is no path by
|
||
which a third-party dependency enters this codebase — a change proposing
|
||
one, for any backend, will not be accepted.
|
||
|
||
## Style
|
||
|
||
Match the register already in the file you're editing — precise,
|
||
constraint-labeled comments citing the `concept.md` section they implement,
|
||
no filler. See the "Documentation standard" section of `CLAUDE.md` for the
|
||
full rule; it applies to code comments and commit messages, not only to
|
||
`.md` files.
|