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
This commit is contained in:
2026-09-14 20:10:25 +00:00
co-authored by Claude Sonnet 5
parent 153d44ee3c
commit 4aad7d6f64
4 changed files with 37 additions and 5 deletions
+10
View File
@@ -35,6 +35,16 @@ pushed to any remote — there is no public release yet, only a local one.
review while investigating a question about data-integrity review while investigating a question about data-integrity
trustworthiness, then confirmed by direct reproduction before and after trustworthiness, then confirmed by direct reproduction before and after
each fix, not merely inspected and assumed correct. each fix, not merely inspected and assumed correct.
- **`tests/test_journal_failure.c` itself leaked memory on its normal,
expected code path** (`vfs_free(v2)` was only called on the unreached
`else` branch, in two separate reopen blocks) — invisible locally
because local sanitizer runs had been using
`ASAN_OPTIONS=detect_leaks=0`, caught by Gitea CI, which does not set
that option. Fixed, and the local/CI sanitizer-invocation mismatch that
let it go unnoticed is now called out explicitly in `CONTRIBUTING.md`.
Also fixed a `-Wunused-result` warning on an intentionally-ignored
`write()` return value in `tests/test_crash_consistency.c`'s progress
side-channel.
### Added ### Added
- `tests/test_crash_consistency.c`: real `fork()`+`SIGKILL` fault - `tests/test_crash_consistency.c`: real `fork()`+`SIGKILL` fault
+20
View File
@@ -91,6 +91,26 @@ 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 (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. observed), not to suppress or silently ignore a `DEADLYSIGNAL` exit.
**A real gap this project's own process had, found the hard way: local
sanitizer runs had been using `ASAN_OPTIONS=detect_leaks=0`, which masked
a genuine memory leak that Gitea CI (which does not set that option) then
caught.** The override wasn't unreasonable on its own terms — a
`SIGKILL`ed forked child (as in `tests/test_crash_consistency.c`) never
runs its own exit-time leak check, so its allocations were never actually
the concern — but setting it for the *entire* test run also suppressed
LeakSanitizer for the parent process's own code, where a real bug
(`tests/test_journal_failure.c` was missing a `vfs_free(v2)` call on its
normal, expected code path, in two separate reopen blocks) went
undetected locally across many runs. **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 CI and a
local run check different things, a real finding can pass locally and
only surface once it reaches CI, which is what happened here. If a fork
+`SIGKILL` test's own allocations genuinely need excluding, scope the
exclusion narrowly (e.g. a leak-suppression file naming the specific
allocation site) rather than disabling leak detection for the whole
binary.
A change to the index structure in `upper.c` (the `TreapNode`/`treap_*`/ A change to the index structure in `upper.c` (the `TreapNode`/`treap_*`/
`node_*` functions, `snapshot_upsert`, `snapshot_remove`, or the `node_*` functions, `snapshot_upsert`, `snapshot_remove`, or the
`UpperSnapshot`/`UpperEntry` representation) should be run through `UpperSnapshot`/`UpperEntry` representation) should be run through
+5 -1
View File
@@ -221,7 +221,11 @@ static void record_progress(const char *progress_path, int n_done) {
if (fd < 0) return; if (fd < 0) return;
char buf[16]; char buf[16];
int len = snprintf(buf, sizeof(buf), "%d", n_done); int len = snprintf(buf, sizeof(buf), "%d", n_done);
write(fd, buf, (size_t)len); /* Best-effort: a short write here only makes read_progress()'s count
* more conservative (see the imprecision note above), never wrong in
* the unsafe direction, so there is nothing more useful to do with a
* partial-write return value than the explicit (void) already says. */
(void)write(fd, buf, (size_t)len);
fsync(fd); fsync(fd);
close(fd); close(fd);
} }
+2 -4
View File
@@ -124,9 +124,8 @@ int main(void) {
vfs_unmount(v2, "/"); vfs_unmount(v2, "/");
backend_free(ov2); backend_free(ov2);
backend_free(mem2); backend_free(mem2);
} else {
vfs_free(v2);
} }
vfs_free(v2);
} }
/* --- scenario 2: vfs_mkdir's journal append fails --- */ /* --- scenario 2: vfs_mkdir's journal append fails --- */
@@ -198,9 +197,8 @@ int main(void) {
vfs_unmount(v2, "/"); vfs_unmount(v2, "/");
backend_free(ov2); backend_free(ov2);
backend_free(mem2); backend_free(mem2);
} else {
vfs_free(v2);
} }
vfs_free(v2);
} }
reset_paths(pack_path, jpath); reset_paths(pack_path, jpath);