#!/bin/bash
#
# Run the existing CTest suites under valgrind and report what leaked.
#
#   scripts/memcheck.sh                 check every suite in ./build
#   scripts/memcheck.sh -R tilemap      check only the suites matching a regex
#   scripts/memcheck.sh -LE perf        skip the perf suites
#
# Anything after the build directory is handed straight to ctest, so every
# selection flag ctest understands works here.
#
# Environment:
#   AKGL_BUILD_DIR    build tree to check (default: build)
#   AKGL_MEMCHECK_LOG directory for the per-suite valgrind logs
#                     (default: $AKGL_BUILD_DIR/Testing/Temporary)
#
# Exit status: 0 when valgrind found nothing, 1 when it found something, 2 on a
# usage or environment error. This is the part `ctest -T memcheck` will not do
# on its own -- it records defects and still exits 0, which is no use as a gate.

set -eu

AKGL_BUILD_DIR="${AKGL_BUILD_DIR:-build}"

function memcheck_die()
{
    echo "memcheck: $*" >&2
    exit 2
}

# The suite runs headless on purpose. Loading the real GPU stack costs thousands
# of unfixable findings inside the vendor driver, and none of them are libakgl's.
# A suite that sets these hints itself is unaffected; the ones that do not --
# tests/tilemap.c and tests/sprite.c -- pick them up from here.
export SDL_VIDEO_DRIVER="${SDL_VIDEO_DRIVER:-dummy}"
export SDL_RENDER_DRIVER="${SDL_RENDER_DRIVER:-software}"
export SDL_AUDIO_DRIVER="${SDL_AUDIO_DRIVER:-dummy}"

command -v valgrind >/dev/null 2>&1 || memcheck_die "valgrind not found"
command -v ctest >/dev/null 2>&1 || memcheck_die "ctest not found"
[ -f "$AKGL_BUILD_DIR/CMakeCache.txt" ] || memcheck_die "$AKGL_BUILD_DIR is not a configured build tree"

AKGL_MEMCHECK_LOG="${AKGL_MEMCHECK_LOG:-$AKGL_BUILD_DIR/Testing/Temporary}"

# Old logs would be counted as this run's findings. CTest numbers them by test
# id, so a run narrowed with -R leaves the others behind.
rm -f "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log

# The run itself is allowed to fail: a suite that valgrind makes slow enough to
# trip an assertion still produced a log worth reading, and the log is what
# decides the exit status below.
#
# docs_examples and docs_screenshots are excluded, and the exclusion deserves
# its reasons stated: those two tests are *shell scripts* that drive gcc and
# run the compiled snippets as child processes. Valgrind wraps the test
# command, does not trace children, and so spends the whole test memchecking
# /bin/bash -- 413 of bash's own by-design leaks on the first CI run that got
# this far, and not one byte of libakgl. The snippet programs themselves are
# ordinary akgl consumers whose code paths the suites and example games
# already cover below, under valgrind, as real binaries. A caller's own -E
# overrides this one (ctest takes the last), which is fine: a narrowed run
# chose its scope on purpose.
set +e
ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure \
      -E '^(docs_examples|docs_screenshots)$' "$@"
AKGL_CTEST_STATUS=$?
set -e

# What counts as a finding. "Definitely lost" is a leak nobody can argue with;
# the invalid-access lines are memcheck telling us the library read or wrote
# outside an allocation, which is worse than a leak and easier to fix.
#
# "are definitely lost", not "definitely lost": the LEAK SUMMARY block ends every
# clean log with "definitely lost: 0 bytes in 0 blocks", and matching that
# reported a finding in all twenty-three suites, including the ones that never
# call malloc.
AKGL_MEMCHECK_PATTERN='are definitely lost|Invalid read|Invalid write|Invalid free|Mismatched free|Source and destination overlap|depends on uninitialised|Use of uninitialised'

total=0
echo
echo "== valgrind findings by suite =="
for log in "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log; do
    [ -f "$log" ] || continue
    # CTest writes the test name into the log's first line as the command it ran.
    suite=$(grep -m1 -oE 'akgl_test_[a-z_]+' "$log" || basename "$log")
    count=$(grep -cE "$AKGL_MEMCHECK_PATTERN" "$log" || true)
    total=$((total + count))
    if [ "$count" -gt 0 ]; then
	printf '%-24s %3d finding(s)  %s\n' "$suite" "$count" "$log"
    fi
done

if [ "$total" -eq 0 ]; then
    echo "none"
    echo
    # A clean valgrind run and a failing test are different things: a suite can
    # assert its way to a non-zero exit without leaking a byte, and saying
    # "clean" and returning 0 for that would hide it.
    if [ "$AKGL_CTEST_STATUS" -ne 0 ]; then
	echo "memcheck: valgrind found nothing, but ctest exited $AKGL_CTEST_STATUS -- a suite failed on its own terms" >&2
	exit 1
    fi
    echo "memcheck: clean ($AKGL_MEMCHECK_LOG)"
    exit 0
fi

echo
echo "== library frames in those stacks, most frequent first =="
# Keyed on the function name rather than the file, because src/physics.c and
# tests/physics.c are both "physics.c" in a valgrind frame and only one of them
# is the library. Anything a caller can reach is an akgl_ symbol by convention,
# which makes the prefix a usable filter; a static helper appears under whatever
# akgl_ function called it, one frame further down.
grep -hoE 'by 0x[0-9A-Fa-f]+: akgl_[A-Za-z0-9_]+ \([a-z_]+\.c:[0-9]+\)' \
    "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log 2>/dev/null |
    sed -E 's/^by 0x[0-9A-Fa-f]+: //' | sort | uniq -c | sort -rn | head -20

echo
echo "memcheck: $total finding(s); full stacks in $AKGL_MEMCHECK_LOG" >&2
exit 1
