bug-1971: preserve flaky test-failure evidence across xce-build.sh runs

A failed test run's evidence (meson-logs/testlog.txt + the mkdtemp sweep's
FAILED-case /tmp/xce_* fixture dirs) was destroyed by the next build:
setup_build rm -rf's the build dir, and any subsequent meson test overwrites
testlog.* in place. t2 2026-10-22: a controller case failed at ~05:46, the
make clean + rerun at ~05:52 left 134/134 OK and zero trace of what failed.

Two chokes in xce-build.sh:
- finalize_failure_evidence (EXIT trap): on rc!=0 with RUN_TESTS=1, copy
  meson-logs to testlogs.archive/<BUILD_DIR>/this-<stamp>-rc<N>/ and drop
  the .meson_test_failed_rc marker; also releases the build lock (the old
  lock-release trap folded in).
- archive_previous_failure (run start, pre-wipe): if the marker exists,
  move the previous run's meson-logs to prev-<stamp>-rc<N>/ -- outside the
  build dir, unreachable by the wipe; sweep aged (>5 min) /tmp/xce_*
  FAILED-case fixture dirs into prev-<stamp>-fixtures/ so they sit with the
  testlog that explains them; cap the archive at newest 10 per build dir.

Also -type d on BOTH /tmp/xce_* sweeps (this one and bug-924's
prune_stale_tmp_fixtures): mkdtemp fixtures are always directories, but
the bare name also matches regular files -- the untyped find live-moved
daemon debug logs (xce_scheduler.log, xce_strace*.log) into the archive,
and the bug-924 variant rm -rf's its matches.

Test: src/tools/test-failure-evidence-archive.sh, 6 scenarios / 11
assertions exercising the real function bodies (sourced with surgical
stubbing) -- all green.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-22 08:08:01 +00:00
parent e8cb6d5743
commit 68c3ccd773
3 changed files with 464 additions and 3 deletions

95
.wolf/bugs/bug-1971.md Normal file
View file

@ -0,0 +1,95 @@
---
id: "bug-1971"
status: "fixed"
status_updated: "2026-10-22"
status_note: "two-choke archive in xce-build.sh; test harness green"
component: "build"
severity: "wrong-result"
timestamp: "2026-10-22T07:50:00Z"
last_seen: "2026-10-22T07:50:00Z"
occurrences: 2
files: ["src/tools/xce-build.sh"]
tags: ["test", "flaky", "observability", "build"]
related: []
---
# bug-1971: next xce-build.sh run destroys the prior testlog — flaky-failure evidence unrecoverable
## Description
**Observed:** (t2, 2026-10-22) a full `make build` (asan profile) failed on a controller test case; a following `make clean` + `make build` passed 134/134. The failing run's `meson-logs/testlog.{txt,json,junit.xml}` had been destroyed twice over: `setup_build`'s unconditional `rm -rf "$BUILD_DIR"` deletes the whole build dir (meson-logs included) before the retry, and meson itself overwrites `testlog.*` on every `meson test` invocation regardless. Nothing in the surviving tree identifies which case failed: the worker suite's daemon logs (`/tmp/test_xcecd_resync.log` and rotations) showed no failure signature, and no `sched_test`/Go test output is retained anywhere.
Reconstruction from filesystem timestamps alone (`/tmp/test_*.log` rotations, `meson-log.txt` build options line, `/usr/local/go` ctime, bash history) established the two-run timeline but could not name the failed case.
The same evidence-loss shape hit t1 earlier (2026-10-21, the two controller flakes fixed in 5b9d7270) — there the testlog happened to still exist because no clean rebuild ran in between; that was luck, not design.
**Expected:** when a build/test run fails, its meson-logs (testlog.txt/json/junit.xml) and any per-case fixtures it kept (the mkdtemp sweep contract deliberately preserves FAILED cases' `/tmp/xce_*` dirs) survive at least until the next run starts — so a flaky failure can be diagnosed from disk afterwards without re-running and hoping to reproduce.
**Impact:** intermittent/flaky test failures (the exact class this project keeps hitting: 5b9d7270's two flakes, bug-1521's random heap corruption, the t2 2026-10-22 case) leave zero durable evidence. Each recurrence costs a full rebuild cycle and often fails to reproduce. Also buries the mkdtemp "failed cases keep their dirs for post-mortem" contract, since the next `make build` never looks for those dirs and a clean build gives the appearance of none existing.
**Found by:** user report (t2 build failure, 2026-10-22) + post-mortem on the t2 filesystem.
## Environment
- OS: Ubuntu 24.04.2 LTS (t1 and t2); kernel Linux 6.8.0-139-generic x86_64
- libc: glibc 2.39 (host ldd)
- Toolchain: gcc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1), meson 1.12.1
- Build: asan (xce.build)
- Code: build/de-nix @ 5b9d7270 (t2 checkout), e8cb6d57 (t1)
- Deployment: n/a — dev-loop build/test on t1 (workstation) and t2 (8-core/8GB test node)
## Reproduction
1. On any machine: `make build` (or `bash src/tools/xce-build.sh`) and let the test phase fail (e.g. by patching a test to fail).
2. Note `src/xce.build/meson-logs/testlog.txt` contains the failure.
3. Run `make clean && make build` again: the previous testlog is gone (build dir was rm -rf'd), and even without `make clean`, a successful `meson test` run overwrites testlog.* in place.
- Frequency: deterministic — every subsequent run destroys the prior run's evidence.
- Evidence: t2 `/tmp/myxce` 2026-10-22: failing run ~05:46, passing rerun 05:52 (its testlog survives at `/tmp/myxce/src/xce.build/meson-logs/testlog.txt`, 134/134 OK); the failing run's testlog was deleted by the `make clean` at ~05:5x.
## Root cause
`setup_build()` in `src/tools/xce-build.sh` unconditionally `rm -rf "$BUILD_DIR"` on every non-incremental invocation, and `meson test` overwrites `$BUILD_DIR/meson-logs/testlog.*` on every run. xce-build.sh never copies failure state anywhere durable, and nothing else in the tree does either.
## Fix
Two chokes in `src/tools/xce-build.sh` (this commit):
1. `archive_previous_failure()` — runs at script start, before `setup_build`'s
wipe. If the marker `.meson_test_failed_rc` (written by the previous run's
EXIT trap) exists, the previous run's `meson-logs` are MOVED to
`$SRC_DIR/testlogs.archive/<BUILD_DIR>/prev-<stamp>-rc<N>/` — outside the
build dir, so the wipe can't touch them. It also sweeps aged (>5 min, i.e.
not a concurrent session's) `/tmp/xce_*` FAILED-case fixture DIRS into
`prev-<stamp>-fixtures/`, and caps the archive at the newest 10 entries
per build dir.
2. `finalize_failure_evidence()` — the EXIT trap (replaces the old
lock-release trap, which becomes its last statement). On rc≠0 with
RUN_TESTS=1, COPIES `meson-logs` to
`testlogs.archive/<BUILD_DIR>/this-<stamp>-rc<N>/` and writes the marker.
Copy-not-move: meson-logs still belongs to the live build dir for this
run's own inspection; the next run moves the copy out at its start.
Configure/compile failures (RUN_TESTS unset) archive nothing — those are
deterministic and self-explanatory on rerun; it's the testlog-bearing
flaky failures that need preservation.
Additionally `-type d` added to BOTH `/tmp/xce_*` sweeps (this one and
bug-924's `prune_stale_tmp_fixtures`): mkdtemp fixtures are always
directories (`/tmp/xce_<label>_XXXXXX`), but the bare name also matches
regular files — the untyped find live-moved daemon debug logs
(`/tmp/xce_scheduler.log`, `/tmp/xce_strace*.log`) into the archive on
2026-10-22, and the bug-924 variant `rm -rf`s its matches.
## Verification
- Test: `src/tools/test-failure-evidence-archive.sh` — 6 scenarios /
11 assertions exercising the real function bodies (sourced with surgical
stubbing): failed run archives as this-*-rc3 + marker; success archives
nothing; next run moves prior failure to prev-*-rc3 + consumes marker;
aged fixture dirs move / fresh stay / regular files never touched;
retention caps at newest 10; RUN_TESTS=0 archives nothing. All green
(11 passed, 0 failed, bash, 2026-10-22).
- Suites: n/a — this bug lives in the build script, not in C code; no meson
suite exercises it. `bash -n` syntax check green.
- Real wipe path not exercised by the harness test (it stubs meson);
validated by inspection: the archive root is `$SRC_DIR/testlogs.archive/`,
which `rm -rf "$BUILD_DIR"` cannot reach.
- Live: first t2/sandbox failing run after this commit leaves
`testlogs.archive/xce.build/this-*-rc*` with the testlog — that is the
evidence this bug asks for.
## References
- Related: none

View file

@ -0,0 +1,239 @@
#!/bin/bash
# test-failure-evidence-archive.sh — bug-1971: a failed xce-build.sh run must
# leave its meson-logs somewhere the NEXT run's wipe cannot destroy, and the
# next run must move the previous failure's evidence (testlog + the mkdtemp
# sweep's FAILED-case /tmp/xce_* fixture dirs) into the timestamped archive
# before setup_build's rm -rf.
#
# Exercises src/tools/xce-build.sh's archive functions directly (sourced),
# so no real build is needed:
# 1. a failed run's EXIT trap archives meson-logs + writes the rc marker
# 2. a successful run's EXIT trap archives nothing
# 3. archive_previous_failure moves a previous failure's meson-logs out of
# the build dir and consumes the marker (a later wipe can't destroy it)
# 4. leftover /tmp/xce_* fixture DIRS (>5 min old) are moved into the
# archive; fresh ones (a concurrent session's) are left alone; regular
# files matching xce_* (daemon debug logs are not fixtures) are never
# touched
# 5. retention: only the newest 10 archive entries per build dir survive
# 6. a non-test failure (RUN_TESTS=0, e.g. compile error) archives nothing
#
# Usage:
# bash src/tools/test-failure-evidence-archive.sh
set -u
TOOLS=$(cd "$(dirname "$0")" && pwd)
PASS=0
FAIL=0
green() { printf '\033[32m%s\033[0m\n' "$*"; }
red() { printf '\033[31m%s\033[0m\n' "$*"; }
pass() { green " PASS: $*"; PASS=$((PASS+1)); }
fail() { red " FAIL: $*"; FAIL=$((FAIL+1)); }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# The extracted slice starts with xce-build.sh's `set -euo pipefail`, which
# would make rc_probe's `return 3` abort THIS script mid-test. Neutralize it
# right after sourcing: this test drives the failure paths itself and does
# not want -e's early exit (the real script keeps its own set -e line --
# only the test copy sheds it).
STRICT_SAVED_OPTS="$-"
# Source the build script's functions without running it: xce-build.sh is a
# straight-through script (no main guard), so sourcing would execute it.
# Extract the slice that matters: from the top of the file through the
# definitions of archive_root / archive_previous_failure /
# finalize_failure_evidence, stopping at the bare `archive_previous_failure`
# call (everything after that — the component case dispatch — is not under
# test). The extracted slice still contains the arg parsing, the toolchain
# preflight, the --jobs live-default block and the build-lock sourcing; stub
# those out with targeted sed removals so the test exercises ONLY the real
# function bodies (if someone edits them, this test sees the edit).
CUT=$(grep -n '^archive_previous_failure$' "$TOOLS/xce-build.sh" | head -1 | cut -d: -f1)
if [ -z "$CUT" ]; then
fail "cannot locate archive_previous_failure call in xce-build.sh (structure changed?)"
echo "Results: ${PASS} passed, ${FAIL} failed"
exit 1
fi
head -n "$CUT" "$TOOLS/xce-build.sh" > "$WORK/functions.sh"
python3 - "$WORK/functions.sh" <<'PYEOF'
import re, sys
p = sys.argv[1]
s = open(p).read()
# 1. toolchain preflight: fake every tool as present
s = re.sub(r"for t in meson ninja[^)]*\).*?^fi\n", "", s, flags=re.M | re.S)
# 2. pkg-config system-library preflight block: drop entirely
s = re.sub(r"missing_pkgs=\(\)\nwhile IFS= read -r line.*?^fi\n", "", s, flags=re.M | re.S)
# 3. --jobs parsing/validation + live-default computation: not under test.
# Cut from the INCREMENTAL=0 marker through the MESON_TEST_JOB_OPTS line
# INCLUSIVE -- but no further: the archive function definitions below it
# must survive (a `.*$` tail here would eat them). GO_TEST_JOB_OPTS (one
# line after the cut point's sibling MESON_* assignment) still references
# $JOBS -- stub it too; the test never passes Go opts anywhere.
s = re.sub(r"INCREMENTAL=0\nJOBS=\"\"\nARGS=\(\).*?^MESON_TEST_JOB_OPTS=\([^\n]*\)\n", "", s, flags=re.M | re.S)
s = re.sub(r"^GO_TEST_JOB_OPTS=\(-p \"\$JOBS\"\)\n", "GO_TEST_JOB_OPTS=()\n", s, flags=re.M)
# 4. profile case: provide RUN_TESTS/BUILD_DIR assignment via test env instead
s = re.sub(r"case \"\$\{2:-\}\" in.*?^esac\n", "", s, flags=re.M | re.S)
s = re.sub(r"if \[ -z \"\$\{BUILD_DIR:-\}\" \]; then.*?^fi\n", "", s, flags=re.M | re.S)
s = re.sub(r"# RUN_TESTS: the unit suites.*?^esac\n", "", s, flags=re.M | re.S)
# 5. build-lock sourcing: the test stubs the lock itself
s = s.replace('. "$SCRIPT_DIR/build-lock.sh"', ': # build-lock not under test')
s = s.replace('build_lock_acquire "$BUILD_DIR"', ': # lock not under test')
s = s.replace('build_lock_release', ': # lock not under test')
s = s.replace('trap finalize_failure_evidence EXIT', ': # trap installed per-test')
# 6. the bare pre-dispatch calls + component dispatch tail: drop
s = s.replace('prune_stale_tmp_fixtures\n', ': # prune not under test\n')
s = s.replace('archive_previous_failure\n', ': # called per-test\n')
s = s.replace('cd "$SRC_DIR"\n', ': # cd per-test\n')
open(p, "w").write(s)
PYEOF
# Any stray execute-side reference left by the surgery would print here —
# catch it loudly instead of silently passing with the real script never run.
if bash -n "$WORK/functions.sh" 2>&1 | grep -q .; then
fail "extracted functions.sh has a syntax error after stubbing"
bash -n "$WORK/functions.sh"
echo "Results: ${PASS} passed, ${FAIL} failed"
exit 1
fi
# shellcheck source=/dev/null
. "$WORK/functions.sh"
set +e # undo the sourced slice's `set -euo pipefail` (see note above)
set +u
set +o pipefail
SRC_DIR="$WORK/src"
mkdir -p "$SRC_DIR"
echo "── 1. failed run (RUN_TESTS=1): EXIT trap archives meson-logs + writes marker ──"
# Simulate what the EXIT trap sees when the script dies with rc=3: leave $? = 3
# via a failing function, then call the finalizer (bash's trap semantics: the
# trap sees the script's exit status in $?).
BUILD_DIR="$WORK/b1"
RUN_TESTS=1
mkdir -p "$BUILD_DIR/meson-logs"
echo "fake testlog with a failure" > "$BUILD_DIR/meson-logs/testlog.txt"
echo "fake json" > "$BUILD_DIR/meson-logs/testlog.json"
rc_probe() { return 3; }
rc_probe # $? = 3
finalize_failure_evidence
arc="$(printf '%s/testlogs.archive/%s' "$SRC_DIR" "$BUILD_DIR")"
entries="$(ls "$arc" 2>/dev/null | grep -c '^this-.*-rc3$')"
if [ "$entries" = "1" ] && [ -f "$arc/this-"*"-rc3/meson-logs/testlog.txt" ] \
&& [ "$(cat "$BUILD_DIR/.meson_test_failed_rc" 2>/dev/null)" = "3" ]; then
pass "meson-logs archived as this-*-rc3 with marker .meson_test_failed_rc=3"
else
fail "expected this-*-rc3 archive entry + marker; got: $(ls "$arc" 2>/dev/null)"
fi
# original still in place (copy, not move)
if [ -f "$BUILD_DIR/meson-logs/testlog.txt" ]; then
pass "original meson-logs left in the build dir (copy semantics)"
else
fail "meson-logs was MOVED out of the build dir -- this run's own inspection loses it"
fi
echo "── 2. successful run: EXIT trap archives nothing ──"
arc2="$(printf '%s/testlogs.archive/%s' "$SRC_DIR" "$WORK/b2")"
BUILD_DIR="$WORK/b2"
RUN_TESTS=1
mkdir -p "$BUILD_DIR/meson-logs"
echo "all green" > "$BUILD_DIR/meson-logs/testlog.txt"
true # $? = 0
finalize_failure_evidence
if [ ! -e "$arc2" ]; then
pass "no archive entry on rc=0"
else
fail "archive entry created on a successful run: $(ls "$arc2")"
fi
if [ ! -f "$BUILD_DIR/.meson_test_failed_rc" ]; then
pass "no marker written on rc=0"
else
fail "marker written on rc=0"
fi
echo "── 3. next run's archive_previous_failure moves the previous failure out ──"
BUILD_DIR="$WORK/b1" # still has meson-logs + marker from step 1
mkdir -p "$BUILD_DIR"
archive_previous_failure
prev="$(ls "$arc" 2>/dev/null | grep -c '^prev-.*-rc3$')"
if [ "$prev" = "1" ] && [ -f "$arc/prev-"*"-rc3/meson-logs/testlog.txt" ]; then
pass "previous failure's meson-logs now at prev-*-rc3 (out of the wipe's path)"
else
fail "expected prev-*-rc3 entry with testlog; got: $(ls "$arc" 2>/dev/null)"
fi
if [ ! -f "$BUILD_DIR/.meson_test_failed_rc" ]; then
pass "marker consumed after archiving (a later passing run won't re-archive)"
else
fail "marker not consumed"
fi
echo "── 4. /tmp/xce_* fixtures: old dirs move, fresh stay, regular files never touched ──"
fresh_fixture="/tmp/xce_test_evidence_fresh_$$"
old_fixture="/tmp/xce_test_evidence_old_$$"
plain_file="/tmp/xce_test_evidence_file_$$" # regular file, not a mkdtemp fixture
mkdir -p "$fresh_fixture" "$old_fixture"
: > "$plain_file"
touch -d '30 minutes ago' "$old_fixture" "$plain_file" # aged: not a concurrent session's
BUILD_DIR="$WORK/b3"
archive_previous_failure
arc3="$(printf '%s/testlogs.archive/%s' "$SRC_DIR" "$WORK/b3")"
if [ -d "$arc3/prev-"*"-fixtures/xce_test_evidence_old_$$" ] && [ ! -e "$old_fixture" ]; then
pass "aged /tmp/xce_* fixture moved into the archive"
else
fail "aged fixture not archived: $(ls -d "$arc3/prev-"*"-fixtures" 2>/dev/null)"
fi
if [ -d "$fresh_fixture" ]; then
pass "fresh (<5 min) fixture left alone (concurrent session's)"
else
fail "fresh fixture was moved -- a concurrent session's run would lose it"
fi
if [ -f "$plain_file" ]; then
pass "regular file xce_* NOT moved (only mkdtemp dirs are fixtures; xce_* also matches daemon debug logs)"
else
fail "regular file was moved -- the sweep is over-matching non-fixtures"
fi
rm -rf "$fresh_fixture"
rm -f "$plain_file"
echo "── 5. retention: newest 10 survive ──"
BUILD_DIR="$WORK/b5"
arc5="$(printf '%s/testlogs.archive/%s' "$SRC_DIR" "$WORK/b5")"
mkdir -p "$arc5"
for i in $(seq -w 1 15); do mkdir -p "$arc5/prev-20261022-0000$i-rc1"; done
archive_previous_failure
count="$(find "$arc5" -mindepth 1 -maxdepth 1 -type d | wc -l)"
if [ "$count" = "10" ] && [ -d "$arc5/prev-20261022-000015-rc1" ] && [ ! -d "$arc5/prev-20261022-000005-rc1" ]; then
pass "archive capped at newest 10 (000015 kept, 000005 pruned)"
else
fail "retention wrong: $count entries"
fi
echo "── 6. non-test failure (RUN_TESTS=0): nothing archived ──"
BUILD_DIR="$WORK/b6"
RUN_TESTS=0
mkdir -p "$BUILD_DIR/meson-logs"
echo "compile error log" > "$BUILD_DIR/meson-logs/meson-log.txt"
rc_probe2() { return 2; }
rc_probe2 # $? = 2
finalize_failure_evidence
arc6="$(printf '%s/testlogs.archive/%s' "$SRC_DIR" "$WORK/b6")"
if [ ! -e "$arc6" ]; then
pass "compile-error path archives nothing (deterministic, self-explanatory)"
else
fail "archive entry created for a non-test failure"
fi
echo
if [ "$FAIL" = 0 ]; then
green "Results: ${PASS} passed, ${FAIL} failed"
exit 0
else
red "Results: ${PASS} passed, ${FAIL} failed"
exit 1
fi

View file

@ -232,9 +232,14 @@ cd "$SRC_DIR"
# against the same dir raced on setup_build's rm -rf ("Directory not empty")
# or deleted a binary the other run had just built; it now waits here until
# this run exits. Runs against different build dirs are not affected.
#
# The EXIT trap is finalize_failure_evidence (defined below, bug-1971): it
# archives this run's meson-logs on a failed test run AND releases the
# build lock -- so the lock release that used to be its own trap line is now
# the finalizer's last statement.
. "$SCRIPT_DIR/build-lock.sh"
build_lock_acquire "$BUILD_DIR"
trap build_lock_release EXIT
trap finalize_failure_evidence EXIT
# build-time-versioning task 2.1: compute commit_id/build_time once, common to
# every profile, and append to MESON_OPTS below (before either `meson setup`
@ -363,8 +368,11 @@ usage() {
# test run just created -- this sandbox routinely has multiple sessions
# building/testing at once.
prune_stale_tmp_fixtures() {
# -type d for the same reason as archive_previous_failure below: mkdtemp
# fixtures are directories; xce_* also matches regular files (daemon debug
# logs), and this function rm -rf's its matches.
local stale
stale="$(find /tmp -maxdepth 1 -name 'xce_*' -mmin +180 2>/dev/null)"
stale="$(find /tmp -maxdepth 1 -name 'xce_*' -type d -mmin +180 2>/dev/null)"
if [ -n "$stale" ]; then
local count
count="$(printf '%s\n' "$stale" | wc -l)"
@ -384,7 +392,126 @@ setup_build() {
fi
}
prune_stale_tmp_fixtures
# bug-1971: this script destroys test-failure evidence by design of its own
# default flow. setup_build() rm -rf's the whole build dir (meson-logs/
# testlog.* with it), and even without the wipe meson overwrites testlog.* on
# every `meson test`. A flaky failure (5b9d7270's two, bug-1521's random heap
# corruption, t2 2026-10-22) therefore leaves NOTHING on disk: the user sees
# "failed once, passes on rerun" and the failed case can never be named. Two
# chokes fixes it, both here because every test run in this project goes
# through this script:
#
# 1. BEFORE the wipe (archive_previous_failure, called just below): if the
# PREVIOUS run in this build dir failed -- meson's own marker file
# .meson_test_failed_rc (written by the EXIT trap, see
# finalize_failure_evidence) or FAILED-case /tmp/xce_* fixture dirs the
# mkdtemp shim deliberately preserved for post-mortem (see
# testsuite_support/mkdtemp_clean.c: passed cases are swept, failed ones
# keep their dirs) -- move that evidence into a timestamped,
# never-wiped archive dir first.
#
# 2. ON FAILURE (finalize_failure_evidence, EXIT trap): if THIS run exits
# nonzero after running tests, copy meson-logs/ out before the next
# run's wipe. EXIT (not explicit calls) so every failure path is
# covered: set -e aborts, meson test nonzero, Go test nonzero, Ctrl-C
# (INT triggers EXIT in bash), the lot.
#
# Archives live under $SRC_DIR/testlogs.archive/<build_dir>/<timestamp>-rc<N>/
# -- OUTSIDE the build dir so the wipe never touches them, inside the repo so
# they sit next to the sources they belong to. Retention: keep the newest 10
# per build dir, prune older ones (the archive is capped, not unbounded --
# testlogs are a few MB each, fixtures can be bigger, so the cap matters).
# The fixture sweep moves rather than copies dirs, and de-duplicates by
# timestamp to the second so a same-second double invocation can't collide
# (mktemp suffix if it somehow would).
archive_root() {
printf '%s/testlogs.archive/%s' "$SRC_DIR" "$BUILD_DIR"
}
archive_previous_failure() {
local stamp arc
stamp="$(date +%Y%m%d-%H%M%S)"
# 1a. The previous run's own meson-logs, if it failed. finalize_failure_
# evidence leaves .meson_test_failed_rc holding the exit code; no
# marker = last run passed or never ran tests, nothing to do.
local marker="$BUILD_DIR/.meson_test_failed_rc"
if [ -f "$marker" ]; then
local prev_rc
prev_rc="$(cat "$marker" 2>/dev/null || echo '?')"
arc="$(archive_root)/prev-$stamp-rc$prev_rc"
if [ -d "$BUILD_DIR/meson-logs" ]; then
mkdir -p "$arc"
mv "$BUILD_DIR/meson-logs" "$arc/" 2>/dev/null || true
echo "=== bug-1971: PREVIOUS run (rc=$prev_rc) failed -- its meson-logs archived to $arc (they would have been destroyed by this run's wipe) ==="
fi
rm -f "$marker"
fi
# 1b. FAILED-case /tmp/xce_* fixture dirs (mkdtemp sweep contract: passed
# cases are swept immediately, FAILED cases keep their dirs). They
# survive this run's wipe on disk, but nothing else ever cleans or
# curates them -- move them into the archive so they sit WITH the
# testlog that explains them instead of leaking in /tmp until the
# 3h prune (prune_stale_tmp_fixtures) destroys them unread. Skip the
# ones a concurrent session's still-running test owns (mtime < 5 min
# old): those are not from the previous run.
# -type d: fixtures are always mkdtemp DIRECTORIES (/tmp/xce_<label>_XXXXXX,
# e.g. test_bug857_regression.c:176, test_persist_wal.c:41); the bare
# xce_* name also matches regular files -- daemon debug logs like
# /tmp/xce_scheduler.log, /tmp/xce_strace*.log from cluster debugging
# are NOT fixtures and must stay in /tmp (live-hit 2026-10-22: the
# untyped find moved all five into this archive).
local stale
stale="$(find /tmp -maxdepth 1 -name 'xce_*' -type d -mmin +5 2>/dev/null || true)"
if [ -n "$stale" ]; then
arc="$(archive_root)/prev-$stamp-fixtures"
mkdir -p "$arc"
local moved=0
while IFS= read -r d; do
[ -z "$d" ] && continue
mv "$d" "$arc/" 2>/dev/null && moved=$((moved + 1))
done <<< "$stale"
if [ "$moved" -gt 0 ]; then
echo "=== bug-1971: moved $moved leftover /tmp/xce_* FAILED-case fixture dir(s) to $arc ==="
else
rmdir "$arc" 2>/dev/null || true
fi
fi
# 1c. Retention: cap each build dir's archive at its newest 10 entries.
# -mindepth 1 so the archive root itself is never a rm candidate.
local root
root="$(archive_root)"
if [ -d "$root" ]; then
find "$root" -mindepth 1 -maxdepth 1 -type d | sort -r | tail -n +11 | xargs -r rm -rf -- 2>/dev/null || true
fi
}
# EXIT trap: this run failed after running tests -> copy meson-logs to the
# archive and drop the marker for the NEXT run's archive_previous_failure.
# Copy (not move): meson-logs still belongs to the live build dir for this
# run's own inspection; the next run moves the copy out at ITS start (1a).
# Guarded on RUN_TESTS=1 and meson-logs existing -- a configure/compile
# failure has no testlog worth archiving (meson-log.txt IS in meson-logs,
# but a compile error is deterministic and self-explanatory on rerun; it's
# the testlog-bearing flaky failures that need this). INT/TERM fall through
# to EXIT in bash, so Ctrl-C mid-test is covered too.
finalize_failure_evidence() {
local rc=$?
if [ "$rc" -ne 0 ] && [ "${RUN_TESTS:-0}" = 1 ] && [ -d "$BUILD_DIR/meson-logs" ]; then
local stamp arc
stamp="$(date +%Y%m%d-%H%M%S)"
arc="$(archive_root)/this-$stamp-rc$rc"
mkdir -p "$arc"
cp -r "$BUILD_DIR/meson-logs" "$arc/" 2>/dev/null || true
printf '%s\n' "$rc" > "$BUILD_DIR/.meson_test_failed_rc" 2>/dev/null || true
echo "=== bug-1971: this run FAILED (rc=$rc) -- meson-logs archived to $arc; the NEXT run will also move it out of the wipe's path ==="
fi
build_lock_release
}
archive_previous_failure
case "$COMPONENT" in
lib)