Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions tools/scripts/lsan.supp
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,14 @@
# and CreateMpvContex() (MpvBackend.cpp). Confirmed via backend_scene_tests'
# "a destroyed decoder holds no dma-buf from the frames it rendered" case.
leak:mpv_render_context_create

# Fontconfig 2.18.3 retains 895 B / 9 allocations from parsing its implicit
# global configuration. A library-only FcConfigSubstitute(NULL, ...) probe
# reproduces this after destroying the request pattern; 100 calls retain the
# same amount. FcFini clears it, but a Plasma plugin does not own global state.
# Match config loading, not all FcPattern allocations or first-party callers.
# This also excludes config-parser allocations if a caller leaks an explicitly
# owned FcConfig; ordinary request/match-pattern leaks remain detectable.
# See issue #41. No DBus/NVIDIA suppression: unloaded frames cannot be matched
# reliably without also hiding allocations unrelated to the verified driver.
leak:FcConfigParseAndLoad
42 changes: 28 additions & 14 deletions tools/scripts/preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ if [[ "$MODE" == "sanitize" ]]; then
*)
# ASAN/UBSAN (and any address/undefined combo). FATAL: the submodule
# doctest suites are audited clean under address+undefined (see
# tools/scripts/lsan.supp for the one confirmed system-library leak
# tools/scripts/lsan.supp for verified system-library allocations
# they suppress). The parent ctest suite now runs under the same
# leak detection, reusing ${asan_opts}/${lsan_opts} below instead of
# its own process-wide override. One of its 26 targets still needs
Expand Down Expand Up @@ -374,11 +374,11 @@ if [[ "$MODE" == "sanitize" ]]; then
# parsers' long-lived caches -- but ASAN_OPTIONS is process-wide,
# so it silenced leak detection for everything the binary links,
# including a real system-library leak inside libmpv/libdrm (see
# lsan.supp). Leak detection now stays ON; the one confirmed leak
# is suppressed by name instead, so a future, unrelated leak still
# lsan.supp). Leak detection stays ON; verified external allocations
# are suppressed by name, so a future, unrelated leak still
# fails this leg. halt_on_error=1 + exit on the first finding so
# the gate fails loudly.
asan_opts="halt_on_error=1:print_stacktrace=1"
asan_opts="detect_leaks=1:halt_on_error=1:print_stacktrace=1"
ubsan_opts="halt_on_error=1:print_stacktrace=1"
lsan_opts="suppressions=${_PF_DIR}/lsan.supp"

Expand Down Expand Up @@ -866,10 +866,10 @@ fi
# wiring is plain text, not a function, so nothing else would catch
# detect_leaks=0 creeping back in or a call site losing its suppressions file
# except actually reading the ASAN gate's own multi-minute output -- this pins
# the wiring in a fraction of a second instead.
# the wiring and real sanitizer failure propagation with a small fixture instead.
step "LSan-suppressions self-test"
if [[ -x tools/scripts/tests/test-lsan-suppressions.sh ]]; then
if ! tools/scripts/tests/test-lsan-suppressions.sh; then
if ! dbox "tools/scripts/tests/test-lsan-suppressions.sh"; then
fail "LSan suppressions self-test failed — preflight.sh's ASAN_OPTIONS/LSAN_OPTIONS wiring or tools/scripts/lsan.supp has drifted, fix it before trusting the ASAN gate's leak detection"
fi
else
Expand Down Expand Up @@ -1063,11 +1063,16 @@ fi
# The parsers + scene runtime are the highest-risk untrusted-input surface and
# are audited clean under address+undefined sanitizers. Gate every push on
# them so an ASAN heap/UAF or UBSAN find blocks the push instead of slipping
# in unobserved. Leak detection stays ON (see tools/scripts/lsan.supp for the
# one confirmed system-library leak it suppresses by name — libmpv/libdrm's
# mpv_render_context_create, not first-party code); halt_on_error=1 so the
# first finding fails the leg. Fresh build dir (build/asan-gate) reused on
# repeat runs; BUILD_FUZZERS=OFF so the fuzzers' own -fsanitize=fuzzer flags
# in unobserved. Leak detection stays ON. The real-GPU lifetime suite reports
# NVIDIA EGL/GBM allocations retained after correct teardown,
# with some frames unloaded before LSan runs (see issue #41). Broad DBus or
# allocator suppressions would hide unrelated leaks. Run that suite separately
# with nonfatal LSan reports; ASAN/UBSAN remain fatal. First-party GPU leaks
# are also visible but nonfatal; LSan cannot distinguish their ownership.
# --sanitize=address,undefined retains the full-suite leak diagnostic.
# Fontconfig config-loading shutdown state is narrowly suppressed in lsan.supp.
# Memory/UB findings fail the leg. Fresh build dir (build/asan-gate) reused
# on repeat runs; BUILD_FUZZERS=OFF so the fuzzers' own -fsanitize=fuzzer flags
# don't double-instrument. The wider parent-tests + full-suite sanitizer run
# is still advisory via --sanitize=address,undefined (parent QML / mpv tests
# not yet audited clean).
Expand All @@ -1086,13 +1091,22 @@ if [[ "$MODE" != "test-only" ]]; then
fi

step "Run backend_scene_tests under ASAN+UBSAN (gate)"
gate_asan_opts="halt_on_error=1:print_stacktrace=1"
gate_asan_opts="detect_leaks=1:halt_on_error=1:print_stacktrace=1"
gate_ubsan_opts="halt_on_error=1:print_stacktrace=1"
gate_lsan_opts="suppressions=${_PF_DIR}/lsan.supp"
gate_gpu_suite='HWVideoTextureDecoder GPU buffer lifetime'
dbox "ASAN_OPTIONS='${gate_asan_opts}' UBSAN_OPTIONS='${gate_ubsan_opts}' LSAN_OPTIONS='${gate_lsan_opts}' \
./build/asan-gate/src/Test/backend_scene_tests" \
./build/asan-gate/src/Test/backend_scene_tests --test-suite-exclude='${gate_gpu_suite}'" \
|| fail "backend_scene_tests: sanitizer finding (ASAN/UBSAN) — investigate then re-run"
ok "backend_scene_tests: ASAN+UBSAN clean"
ok "backend_scene_tests: ASAN+UBSAN+LSan clean (except real-GPU suite below)"

step "Run real-GPU lifetime suite under ASAN+UBSAN (external shutdown leaks not gated)"
# Integrated LSan exitcode=0 also changes ASAN/UBSAN fatal exit codes.
# Abort on memory/UB errors so only leak reports may return success.
dbox "ASAN_OPTIONS='${gate_asan_opts}:abort_on_error=1' UBSAN_OPTIONS='${gate_ubsan_opts}:abort_on_error=1' LSAN_OPTIONS='${gate_lsan_opts}:exitcode=0' \
./build/asan-gate/src/Test/backend_scene_tests --test-suite='${gate_gpu_suite}'" \
|| fail "GPU lifetime suite: sanitizer finding (ASAN/UBSAN) or test failure"
ok "GPU lifetime suite: memory safety checks passed (all LSan leak reports are nonfatal)"

step "Run scenescript_tests under ASAN+UBSAN (gate)"
dbox "ASAN_OPTIONS='${gate_asan_opts}' UBSAN_OPTIONS='${gate_ubsan_opts}' LSAN_OPTIONS='${gate_lsan_opts}' \
Expand Down
155 changes: 122 additions & 33 deletions tools/scripts/tests/test-lsan-suppressions.sh
Original file line number Diff line number Diff line change
@@ -1,28 +1,7 @@
#!/usr/bin/env bash
# Self-test for the ASAN gate's LSan wiring.
#
# preflight.sh's two sanitizer legs used to set ASAN_OPTIONS=detect_leaks=0
# process-wide, which hid a real system-library leak (libmpv's
# mpv_render_context_create, via libdrm's strdup) behind a comment that was
# only ever meant to cover the parsers' long-lived caches. The fix replaced
# the blanket disable with a suppressions file scoped to the one leak that's
# actually been seen, for the submodule doctest suites. The standalone leg's
# parent-ctest call site used to keep its own process-wide detect_leaks=0 for
# two targets that couldn't be suppressed by name; one of those (tst_main_
# integration) got a real ownership fix, and the exclusion for the other
# (tst_webprofileregistry, leaking inside libQt6WebEngineCore with no frame
# narrow enough to suppress by name) moved out of preflight.sh entirely, into
# a per-test ENVIRONMENT override in tests/CMakeLists.txt -- so every other
# parent-ctest target now runs under the same leak detection as the
# submodule suites. Losing any of that -- detect_leaks=0 creeping back
# process-wide, a call site missing LSAN_OPTIONS, or the per-test override
# losing its scoping or its named reason -- would silently reopen the gap
# this file exists to close. No cmake, no build: it reads preflight.sh's and
# tests/CMakeLists.txt's own text.
#
# tools/scripts/tests/test-lsan-suppressions.sh
#
# Exits 0 when every case passes, 1 otherwise.
# Static wiring checks plus real ASAN/UBSAN/LSan regressions for the default
# gate. Only the isolated real-GPU suite makes leaks nonfatal; every suite
# retains LSan reports. Run in the gate's compiler environment.

set -uo pipefail

Expand Down Expand Up @@ -64,24 +43,24 @@ check "parent_asan_opts_variable_no_longer_exists" "$ok" "still present: $leftov

# 2. Every parent-suite-or-submodule ASAN_OPTIONS call site (the ones reading
# ${asan_opts} or ${gate_asan_opts}) must also carry LSAN_OPTIONS on the
# same line -- a partial edit that drops it from one of the five (or a
# sixth site added later without it) would run that leg with LSan on but
# same line -- a partial edit that drops it from one of the six (or a
# seventh site added later without it) would run that leg with LSan on but
# unsuppressed, silently diverging from the others. The parent-ctest call
# site now reuses ${asan_opts}/${lsan_opts} like the submodule suites
# instead of keeping a dedicated variable, so it counts as one of these
# five, not a separate exception -- the one remaining excluded target
# six, not a separate exception -- the one remaining excluded target
# (tst_webprofileregistry) is handled by check 3 instead, as a per-test
# CMake override rather than a call-site-wide disable.
mapfile -t asan_lines < <(
grep -nF "ASAN_OPTIONS='\${asan_opts}'" "$PREFLIGHT"
grep -nF "ASAN_OPTIONS='\${gate_asan_opts}'" "$PREFLIGHT"
grep -nF "ASAN_OPTIONS='\${gate_asan_opts}" "$PREFLIGHT"
)
ok=1; missing=""
for line in "${asan_lines[@]}"; do
[[ "$line" == *"LSAN_OPTIONS="* ]] || { ok=0; missing+="$line"$'\n'; }
done
[[ "${#asan_lines[@]}" -eq 5 ]] || ok=0
check "lsan_options_present_at_every_ASAN_OPTIONS_call_site (${#asan_lines[@]} found, want 5)" "$ok" \
[[ "${#asan_lines[@]}" -eq 6 ]] || ok=0
check "lsan_options_present_at_every_ASAN_OPTIONS_call_site (${#asan_lines[@]} found, want 6)" "$ok" \
"missing LSAN_OPTIONS: $missing"

# 3. tst_webprofileregistry is the one remaining exception: it leaks inside
Expand All @@ -106,7 +85,7 @@ elif ! grep -qi 'leak' <<<"$comment"; then
fi
check "tst_webprofileregistry_keeps_a_scoped_cmake_override_with_named_reason" "$ok" "$detail"

# 4. The suppressions file must exist and carry exactly the one entry verified
# 4. The suppressions file must carry only the entries verified
# against a live LSan run -- not empty (suppresses nothing, reintroduces
# the noise the parser comment worried about) and not a broad first-party
# prefix (hides a real future leak, the exact failure mode this item
Expand All @@ -116,11 +95,121 @@ if [[ ! -f "$LSAN_SUPP" ]]; then
ok=0; detail="$LSAN_SUPP does not exist"
else
mapfile -t entries < <(grep -vE '^\s*(#|$)' "$LSAN_SUPP")
if [[ "${#entries[@]}" -ne 1 || "${entries[0]}" != "leak:mpv_render_context_create" ]]; then
if [[ "${#entries[@]}" -ne 2 || "${entries[0]}" != "leak:mpv_render_context_create" || "${entries[1]}" != "leak:FcConfigParseAndLoad" ]]; then
ok=0; detail="active entries: ${entries[*]:-<none>}"
fi
fi
check "lsan_supp_has_exactly_the_verified_entry" "$ok" "$detail"
check "lsan_supp_has_only_the_verified_entries" "$ok" "$detail"


# 5. Execute the actual default sanitizer run block with a tiny doctest binary.
# Intentional faults must remain fatal, including in the GPU-only process.
# This tests real sanitizer exit codes, not grepping simulated error messages.
probe_dir="$(mktemp -d)" || exit 1
trap 'rm -rf "$probe_dir"' EXIT
mkdir -p "$probe_dir/build/asan-gate/src/Test"
probe_bin="$probe_dir/build/asan-gate/src/Test/backend_scene_tests"
read -r -a font_flags <<<"$(pkg-config --cflags --libs fontconfig)"
if ! "${CXX:-/usr/bin/clang++}" -std=c++17 -g -O0 -fno-omit-frame-pointer \
-fsanitize=address,undefined \
-I"$REAL_ROOT/src/backend_scene/third_party/nlohmann/tests/thirdparty/doctest" \
-x c++ - -o "$probe_bin" "${font_flags[@]}" <<'CPP'
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest.h>
#include <fontconfig/fontconfig.h>
#include <climits>
#include <cstdlib>
#include <cstring>

static bool mode(const char* value) {
const char* actual = std::getenv("SANITIZER_PROBE");
return actual && std::strcmp(actual, value) == 0;
}
__attribute__((noinline)) static void leak() {
void* p = std::malloc(123);
REQUIRE((p != nullptr));
asm volatile("" : : "r"(p) : "memory");
}
__attribute__((noinline)) static void fault(bool outOfBounds = false) {
auto* p = static_cast<volatile char*>(std::malloc(8));
REQUIRE((p != nullptr));
if (!outOfBounds) std::free(const_cast<char*>(p));
p[outOfBounds ? 8 : 0] = 1;
if (outOfBounds) std::free(const_cast<char*>(p));
}
static void overflow() {
volatile int n = INT_MAX;
n += 1;
}
TEST_SUITE("CPU") {
TEST_CASE("first-party memory and ownership") {
if (mode("cpu-leak")) leak();
if (mode("cpu-asan")) fault();
if (mode("cpu-oob")) fault(true);
if (mode("cpu-ubsan")) overflow();
if (mode("font") || mode("pattern-leak")) {
auto* p = FcPatternCreate();
REQUIRE((p != nullptr));
FcPatternAddString(p, FC_FAMILY, reinterpret_cast<const FcChar8*>("sans-serif"));
REQUIRE(FcConfigSubstitute(nullptr, p, FcMatchPattern));
FcPatternDestroy(p);
}
// A leaked application-owned pattern must not match config parsing.
if (mode("pattern-leak")) {
auto* p = FcPatternCreate();
REQUIRE((p != nullptr));
FcPatternAddString(p, FC_FAMILY, reinterpret_cast<const FcChar8*>("sans-serif"));
}
}
}
TEST_SUITE("HWVideoTextureDecoder GPU buffer lifetime") {
TEST_CASE("shutdown allocation remains visible") {
// First-party GPU leaks are also visible but nonfatal in this process.
leak();
if (mode("gpu-asan")) fault();
if (mode("gpu-oob")) fault(true);
if (mode("gpu-ubsan")) overflow();
REQUIRE_FALSE(mode("gpu-assert"));
}
}
CPP
then
echo "FAIL: could not build sanitizer scope regression" >&2
exit 1
fi
# SceneScript has its own executable in production; this fixture selects only
# the CPU cases so an accidental process-wide disable also loses a leak here.
cat > "$probe_dir/build/asan-gate/src/Test/scenescript_tests" <<'SH'
#!/usr/bin/env bash
if [[ "${SANITIZER_PROBE:-}" == scene-leak ]]; then export SANITIZER_PROBE=cpu-leak; fi
exec ./build/asan-gate/src/Test/backend_scene_tests --test-suite=CPU
SH
chmod +x "$probe_dir/build/asan-gate/src/Test/scenescript_tests"
{
printf 'set -euo pipefail\n'
printf '_PF_DIR=%q\n' "$REAL_ROOT/tools/scripts"
printf 'dbox() { bash -c "$1"; }\nstep() { :; }\nok() { :; }\nfail() { echo "$*" >&2; exit 1; }\n'
awk '/^step "Run backend_scene_tests under ASAN\+UBSAN \(gate\)"/ {copy=1}
/^# .*6\. Fuzz smoke/ {copy=0}
copy' "$PREFLIGHT"
} > "$probe_dir/run.sh"
# Pin env options via the production block; inherited sanitizer variables must
# not turn off a fault or inject an unrelated suppression.
for scenario in clean font gpu-leak cpu-leak pattern-leak cpu-asan cpu-oob cpu-ubsan gpu-asan gpu-oob gpu-ubsan gpu-assert scene-leak; do
rc=0
(cd "$probe_dir" && SANITIZER_PROBE="$scenario" bash run.sh) > "$probe_dir/$scenario.log" 2>&1 || rc=$?
ok=0
case "$scenario" in
clean|font|gpu-leak) [[ "$rc" -eq 0 ]] && grep -q 'LeakSanitizer: detected memory leaks' "$probe_dir/$scenario.log" && ok=1 ;;
cpu-leak|pattern-leak|scene-leak) [[ "$rc" -ne 0 ]] && grep -q 'LeakSanitizer' "$probe_dir/$scenario.log" && ok=1 ;;
*-asan) [[ "$rc" -ne 0 ]] && grep -q 'heap-use-after-free' "$probe_dir/$scenario.log" && ok=1 ;;
*-oob) [[ "$rc" -ne 0 ]] && grep -q 'heap-buffer-overflow' "$probe_dir/$scenario.log" && ok=1 ;;
*-ubsan) [[ "$rc" -ne 0 ]] && grep -q 'signed integer overflow' "$probe_dir/$scenario.log" && ok=1 ;;
gpu-assert) [[ "$rc" -ne 0 ]] && grep -q 'REQUIRE_FALSE' "$probe_dir/$scenario.log" && ok=1 ;;
esac
check "runtime sanitizer scope: $scenario" "$ok" "exit=$rc"
[[ "$ok" -eq 1 ]] || cat "$probe_dir/$scenario.log"
done

echo
if [[ "$FAIL" -gt 0 ]]; then
Expand Down