diff --git a/tools/scripts/lsan.supp b/tools/scripts/lsan.supp index f730a95..fffe594 100644 --- a/tools/scripts/lsan.supp +++ b/tools/scripts/lsan.supp @@ -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 diff --git a/tools/scripts/preflight.sh b/tools/scripts/preflight.sh index 188a46a..41381de 100755 --- a/tools/scripts/preflight.sh +++ b/tools/scripts/preflight.sh @@ -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 @@ -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" @@ -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 @@ -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). @@ -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}' \ diff --git a/tools/scripts/tests/test-lsan-suppressions.sh b/tools/scripts/tests/test-lsan-suppressions.sh index 05e62cb..7b36d04 100755 --- a/tools/scripts/tests/test-lsan-suppressions.sh +++ b/tools/scripts/tests/test-lsan-suppressions.sh @@ -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 @@ -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 @@ -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 @@ -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[*]:-}" 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 +#include +#include +#include +#include + +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(std::malloc(8)); + REQUIRE((p != nullptr)); + if (!outOfBounds) std::free(const_cast(p)); + p[outOfBounds ? 8 : 0] = 1; + if (outOfBounds) std::free(const_cast(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("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("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