3 Commits
Author SHA1 Message Date
retoorandClaude Sonnet 5 c414a27e44 Fix CI's actual root cause: a set -e scripting bug, not a code bug
CI / build-and-test (push) Successful in 41s
The reported failure -- "Build and run under ThreadSanitizer" exiting
with code 66 -- traced back to a real bug in .gitea/workflows/ci.yml
itself, confirmed by direct reproduction, not just theorized:

1. Root cause: this step's default shell runs under `set -e`. The
   previous version assigned OUT via a bare, unwrapped
   `OUT=$("./binary" 2>&1)` -- under `-e`, that command's own nonzero
   exit status aborts the whole script immediately, *before* the very
   next line (even `RC=$?`) ever runs. Confirmed with a two-line
   reproduction: `OUT=$(false); echo "after"` under `set -e` never
   prints "after". This meant none of the step's careful classification
   logic (distinguishing the known TSan-can't-start-here limitation from
   a real finding) ever executed -- the first 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
   exists to prevent. Fixed by wrapping every such invocation in
   `if cmd; then RC=0; else RC=$?; fi`, which bash's `-e` rules exempt
   from triggering an abort -- verified directly under `bash -eo
   pipefail` (matching Gitea Actions' actual shell), not assumed correct.

2. Fixing (1) surfaced a second bug: capturing potentially unbounded
   output into a bash variable via `OUT=$(cmd)` is not just slow, it
   reproduced as a several-hundred-megabyte shell string when the
   DEADLYSIGNAL flake's worse, unbounded-repeating-loop form hit during
   this fix's own testing -- and caused the classification logic to
   misbehave at that scale (a real failure was misreported with "exit
   0"). Fixed in both the ASan/UBSan and TSan steps by redirecting
   output to a file and reading back only a bounded 64 KiB prefix for
   classification and logging, never loading the whole thing into a
   shell variable.

3. While repeatedly reproducing the TSan flake locally to verify (1) and
   (2), found a second, previously undocumented variant of the same
   underlying "TSan can't start on this runner" issue: instead of
   printing FATAL: ThreadSanitizer: unexpected memory mapping and
   exiting 66, TSan's broken startup occasionally segfaults outright.
   Confirmed this is the same environmental cause, not a bug in any
   specific test file, by hitting three different, unrelated binaries
   (test_journal_failure in one run, test_crash_consistency and test_dir
   together in another) across repeated full-suite runs -- a real bug
   in one file's code would not migrate to different files at random.
   The TSan step's classification now recognizes this variant too
   (a log containing only timeout's own "dumped core" notice and nothing
   else -- no program output, no real WARNING/SUMMARY ThreadSanitizer
   race report).

4. The ASan/UBSan step's retry budget was bumped from 3 to 5 after
   observing a real 3-in-a-row flake exhaustion in practice during this
   same verification work -- this sandbox's actual flake rate is
   meaningfully higher than the "roughly 1 in 5-10" CONTRIBUTING.md
   documents, and 3 retries turned out not to be a big enough margin.

5. Separately, the -Wunused-result warning on tests/test_crash_consistency.c's
   write() call: the (void) cast that silenced it locally did not silence
   it on the Gitea runner's gcc -- reproduced clean locally with the
   exact same compiler flags, confirming this is a real toolchain
   version/config difference, not a local misconfiguration. void-cast
   suppression of warn_unused_result is documented as unreliable across
   gcc configurations; fixed with an actual conditional branch on the
   return value instead, which every gcc/clang version used so far
   honors.

Every fix here was verified by direct reproduction under `bash -eo
pipefail` locally (matching Gitea Actions' actual shell invocation), not
reasoned about and assumed correct: the original -e bug was reproduced
and fixed, the TSan step was re-run 10 full-suite times (80 individual
binary executions) with both flake variants recurring and both correctly
classified as warnings rather than errors, and the ASan/UBSan step was
re-run 10 full-suite times with the 5-retry budget with zero false
failures. Also verified with a clean make all + make test locally
(zero warnings, all 8 binaries pass).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqJpkdJ6Njnt1pw3CbghzB
2026-09-14 20:55:54 +00:00
retoorandClaude Sonnet 5 4aad7d6f64 Fix real memory leak Gitea CI caught, that local testing had been masking
CI / build-and-test (push) Failing after 38s
Gitea CI flagged two things on the last push:

1. A -Wunused-result warning on an intentionally-ignored write() return
   value in test_crash_consistency.c's progress side-channel. Fixed with
   an explicit (void) cast and a comment explaining why ignoring it is
   safe (a short write there only makes the progress count more
   conservative, per that function's own existing documented tolerance).

2. A real LeakSanitizer failure -- 256 bytes across 4 allocations from
   vfs_new/vfs_unmount. Root cause: tests/test_journal_failure.c's two
   "reopen after the failure, verify recovery" blocks called
   vfs_unmount/backend_free/backend_free inside their `if (ov2)` branch
   (the normal, expected path) but vfs_free(v2) only on the `else`
   branch, which is never actually reached in practice. Fixed by moving
   vfs_free(v2) to run unconditionally after the if, in both blocks.

This bug was invisible locally across many runs because local sanitizer
verification had been using ASAN_OPTIONS=detect_leaks=0 -- adopted
originally for a real reason (a SIGKILLed forked child in
test_crash_consistency.c never runs its own exit-time leak check, so its
allocations were never the actual concern) but applied to the whole test
run, which also suppressed detection of this real bug in the *parent*
process's own code. CI doesn't set that option, so it caught what local
runs couldn't. Documented in CONTRIBUTING.md as a real process gap, not
just a code bug: a local verification habit that diverges from what CI
actually runs can let a real finding through until it reaches CI.

Verified: confirmed the leak directly first (reproduced locally by
dropping the detect_leaks=0 override, matching CI exactly, before
touching any code), then confirmed the fix by re-running the same
no-override sweep across all 8 test binaries with zero leaks found, plus
a clean make all + make test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqJpkdJ6Njnt1pw3CbghzB
2026-09-14 20:10:25 +00:00
retoorandClaude Sonnet 5 e41bd2334a Add real fault-injection crash-safety testing; investigate TSan for real
CI / build-and-test (push) Failing after 38s
Prompted by a direct question about data-integrity trustworthiness: the
crash-safety design (self-checking journal records, atomic-rename
compaction, Section 4.3/4.4) had never actually been tested against a real
crash -- only reasoned about statically and tested against an
already-corrupted file (test_pack_overlay.c, a different scenario).

Added tests/test_crash_consistency.c: real fork()+SIGKILL fault injection
against an actual child process, not a hand-truncated file standing in
for a crash, with empirically-calibrated kill-delay sampling (measured via
throwaway scripts, not guessed) so trials land genuine mid-operation
interruptions rather than always completing first:

- Journal-write crash injection (25 trials): kills a child mid-burst of
  individually-journaled, individually-fsynced writes; verifies a strict
  clean-prefix recovery (every completed write present and correct, every
  write after the kill cleanly absent, no gaps). 20-22/25 trials per run
  land a genuine interruption; zero corruption found.
- Compaction crash injection (40 trials): kills a child mid-vfs_sync;
  verifies every write durably journaled *before* vfs_sync was called
  survives regardless of whether compaction itself completed. 40/40
  trials per run land a genuine interruption; zero corruption found.

A real finding from building this test, not from the code under test: the
first draft reported 128 failures, every one a bug in the test itself --
it couldn't distinguish "the child was killed before ever attempting this
write" from "the write completed and was then lost," since SIGKILL can't
be caught to report progress. Fixed with an independent progress
side-channel (plain write()+fsync() on a separate file, entirely outside
packfs) as ground truth. Documented in CLAUDE.md's new "Known reliability
characteristics" section, including this finding, because a claim is only
as trustworthy as what's measuring it.

Also investigated whether this sandbox's TSan block is actually
unfixable, rather than re-asserting the known limitation: tried
personality(ADDR_NO_RANDOMIZE) directly (EPERM), setarch -R (fails
identically), searched TSAN_OPTIONS for a bypass (none exists), and
checked capabilities/seccomp/unshare --user (zero effective caps, active
seccomp filter, namespace escape also blocked). Confirmed categorical and
documented as such in CLAUDE.md/CONTRIBUTING.md, rather than left as an
unexamined "it doesn't work here."

Also closed a real documentation gap SECURITY.md had: the pack integrity
checksum's non-cryptographic nature (FNV-1a64, already documented in the
context of the compaction-dedup bug) had never been explicitly connected
to its *other* use, load-time pack integrity validation -- added, since
it's a real, relevant caveat for anyone relying on that checksum against
a deliberate adversary rather than accidental corruption.

Verified: clean make all + make test (all 7 binaries), full ASan/UBSan
sweep of all 7 binaries with zero real findings, and the new test run
standalone 4+ times (this session) with stable, consistent results.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqJpkdJ6Njnt1pw3CbghzB
2026-09-14 19:19:21 +00:00