From 4b692de4de37ad872569aa0115187c27376d55cb Mon Sep 17 00:00:00 2001 From: ymatrix1 Date: Tue, 3 Nov 2026 19:51:42 -0300 Subject: [PATCH 1/8] ci: add ASan/UBSan and TSan test workflow --- .github/workflows/sanitizers.yml | 156 +++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 .github/workflows/sanitizers.yml diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 0000000000..461d96f117 --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,156 @@ +name: sanitizers + +on: + workflow_dispatch: + pull_request: + paths: &paths + - "src/**" + - "CMakeLists.txt" + - "CMakeModules/**" + - "externals/**" + - "cpmfile.json" + - ".github/workflows/sanitizers.yml" + push: + branches: + - nx2 + paths: *paths + schedule: + - cron: "0 3 * * 1" + +permissions: + contents: read + +concurrency: + group: sanitizers-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # Fail-closed gate (pattern from prosper's CI): a docs-only PR skips the expensive builds, but + # if this job errors its output is empty and `!= 'false'` still runs the sanitizers. + changes: + name: Detect code changes + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + code_changed: ${{ steps.filter.outputs.code_changed }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 2 + - id: filter + env: + EVENT: ${{ github.event_name }} + run: | + if [ "$EVENT" != "pull_request" ]; then + echo "code_changed=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + CHANGED=$(git diff --name-only HEAD^1 HEAD) + echo "$CHANGED" + if [ -z "$CHANGED" ] || echo "$CHANGED" | grep -qvE '\.md$|^docs/'; then + echo "code_changed=true" >> "$GITHUB_OUTPUT" + else + echo "code_changed=false" >> "$GITHUB_OUTPUT" + fi + + # ASan and TSan cannot share a binary, so each gets its own build of the Catch2 `tests` target. + sanitize: + name: ${{ matrix.name }} + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.code_changed != 'false' }} + runs-on: ubuntu-24.04 + timeout-minutes: 120 + env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 2000M + strategy: + fail-fast: false + matrix: + include: + - name: ASan + UBSan + id: asan-ubsan + flags: -fsanitize=address,undefined -fno-sanitize-recover=undefined + # Fibers switch stacks; the use-after-return fake stack does not follow them. + control: | + int main(int c, char**) { int* p = new int[4]; return p[c + 4]; } + options: >- + ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 + UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 + - name: TSan + id: tsan + flags: -fsanitize=thread + control: | + #include + int g; + int main() { std::thread t([] { g = 1; }); g = 2; t.join(); return g; } + options: TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1 + # Fiber code is a known source of TSan false positives until it is annotated. + # Report failures without blocking until a green baseline exists. + experimental: true + continue-on-error: ${{ matrix.experimental == true }} + + steps: + - name: Check out source + uses: actions/checkout@v5 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + autoconf cmake g++ gcc git glslang-tools libglu1-mesa-dev \ + libpulse-dev libtool libudev-dev mesa-common-dev nasm ninja-build \ + libavfilter-dev libavcodec-dev libswscale-dev pkg-config zlib1g-dev \ + libva-dev libvdpau-dev spirv-tools spirv-headers libusb-1.0-0-dev \ + libboost-dev libboost-fiber-dev libboost-context-dev \ + libopus-dev libasound2-dev ccache catch2 + + - name: Restore ccache + uses: actions/cache@v4 + with: + path: .ccache + key: sanitizers-${{ matrix.id }}-${{ github.sha }} + restore-keys: sanitizers-${{ matrix.id }}- + + # Positive control: a deliberate bug must make the instrumented binary fail. If it passes, + # the flags or runtime are not actually sanitizing and a green test run means nothing. + - name: Verify sanitizer catches a known bug + run: | + cat > control.cpp <<'CPP' + ${{ matrix.control }} + CPP + g++ -g -O1 ${{ matrix.flags }} control.cpp -o control -pthread + if env ${{ matrix.options }} ./control; then + echo "::error::sanitizer positive control passed; instrumentation is not active" + exit 1 + fi + + - name: Configure + run: | + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_C_FLAGS="${{ matrix.flags }} -fno-omit-frame-pointer" \ + -DCMAKE_CXX_FLAGS="${{ matrix.flags }} -fno-omit-frame-pointer" \ + -DCMAKE_EXE_LINKER_FLAGS="${{ matrix.flags }}" \ + -DBUILD_TESTING=ON \ + -DYUZU_TESTS=ON \ + -DENABLE_QT=OFF \ + -DYUZU_CMD=OFF \ + -DYUZU_ROOM=OFF \ + -DYUZU_ROOM_STANDALONE=OFF \ + -DYUZU_USE_BUNDLED_SDL3=ON \ + -DVulkanHeaders_FORCE_BUNDLED=ON \ + -DVulkanUtilityLibraries_FORCE_BUNDLED=ON \ + -DENABLE_UPDATE_CHECKER=OFF \ + -DUSE_DISCORD_PRESENCE=OFF \ + -DENABLE_WERROR=OFF \ + -DUSE_CCACHE=ON + + - name: Build tests + run: cmake --build build --target tests --parallel + + - name: Verify tests are registered + run: | + ctest --test-dir build -N | tee ctest-list.txt + grep -q "Total Tests: [1-9]" ctest-list.txt || { echo "::error::no tests registered"; exit 1; } + + - name: Run tests + run: env ${{ matrix.options }} ctest --test-dir build --no-tests=error --output-on-failure From 0798aadadac5fba6ca18ada35b658c7e2118abbb Mon Sep 17 00:00:00 2001 From: ymatrix1 Date: Tue, 3 Nov 2026 20:13:33 -0300 Subject: [PATCH 2/8] ci: address sanitizer workflow review, cache deps, run TSan off PRs --- .github/workflows/sanitizers.yml | 38 +++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 461d96f117..9322580244 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -8,6 +8,7 @@ on: - "CMakeLists.txt" - "CMakeModules/**" - "externals/**" + - ".patch/**" - "cpmfile.json" - ".github/workflows/sanitizers.yml" push: @@ -56,12 +57,15 @@ jobs: sanitize: name: ${{ matrix.name }} needs: changes - if: ${{ !cancelled() && needs.changes.outputs.code_changed != 'false' }} + # TSan is slow and still noisy, so pull requests run only ASan + UBSan; TSan runs on pushes, + # the weekly schedule and manual dispatch. + if: ${{ !cancelled() && needs.changes.outputs.code_changed != 'false' && (matrix.id != 'tsan' || github.event_name != 'pull_request') }} runs-on: ubuntu-24.04 timeout-minutes: 120 env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 2000M + CPM_SOURCE_CACHE: ${{ github.workspace }}/.cpm-cache strategy: fail-fast: false matrix: @@ -71,7 +75,8 @@ jobs: flags: -fsanitize=address,undefined -fno-sanitize-recover=undefined # Fibers switch stacks; the use-after-return fake stack does not follow them. control: | - int main(int c, char**) { int* p = new int[4]; return p[c + 4]; } + int main(int c, char**) { int* p = new int[4]; volatile int v = p[c + 4]; (void)v; return 0; } + diagnostic: "AddressSanitizer|runtime error" options: >- ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 @@ -81,12 +86,12 @@ jobs: control: | #include int g; - int main() { std::thread t([] { g = 1; }); g = 2; t.join(); return g; } + int main() { std::thread t([] { g = 1; }); g = 2; t.join(); return 0; } + diagnostic: "ThreadSanitizer" options: TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1 - # Fiber code is a known source of TSan false positives until it is annotated. - # Report failures without blocking until a green baseline exists. + # Fiber code is a known source of TSan false positives until it is annotated. Only the + # test run is non-blocking; install, configure, build and registration still gate. experimental: true - continue-on-error: ${{ matrix.experimental == true }} steps: - name: Check out source @@ -118,17 +123,27 @@ jobs: ${{ matrix.control }} CPP g++ -g -O1 ${{ matrix.flags }} control.cpp -o control -pthread - if env ${{ matrix.options }} ./control; then - echo "::error::sanitizer positive control passed; instrumentation is not active" + # The control returns 0 unless a sanitizer intervenes, so require the report text too. + env ${{ matrix.options }} ./control > control.log 2>&1 || true + cat control.log + if ! grep -qE "${{ matrix.diagnostic }}" control.log; then + echo "::error::sanitizer positive control produced no report; instrumentation is not active" exit 1 fi + - name: Restore dependency sources + uses: actions/cache@v4 + with: + path: .cpm-cache + key: cpm-${{ hashFiles('cpmfile.json', 'CMakeModules/**', '.patch/**') }} + restore-keys: cpm- + - name: Configure run: | cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=RelWithDebInfo \ - -DCMAKE_C_FLAGS="${{ matrix.flags }} -fno-omit-frame-pointer" \ - -DCMAKE_CXX_FLAGS="${{ matrix.flags }} -fno-omit-frame-pointer" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_FLAGS="${{ matrix.flags }} -g1 -fno-omit-frame-pointer" \ + -DCMAKE_CXX_FLAGS="${{ matrix.flags }} -g1 -fno-omit-frame-pointer" \ -DCMAKE_EXE_LINKER_FLAGS="${{ matrix.flags }}" \ -DBUILD_TESTING=ON \ -DYUZU_TESTS=ON \ @@ -153,4 +168,5 @@ jobs: grep -q "Total Tests: [1-9]" ctest-list.txt || { echo "::error::no tests registered"; exit 1; } - name: Run tests + continue-on-error: ${{ matrix.experimental == true }} run: env ${{ matrix.options }} ctest --test-dir build --no-tests=error --output-on-failure From 4db5ab3e04dd2b394672b43495483bcf48f78a68 Mon Sep 17 00:00:00 2001 From: ymatrix1 Date: Tue, 3 Nov 2026 20:14:29 -0300 Subject: [PATCH 3/8] ci: pick sanitizer matrix in the gate job --- .github/workflows/sanitizers.yml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 9322580244..bcfe722533 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -34,10 +34,22 @@ jobs: timeout-minutes: 10 outputs: code_changed: ${{ steps.filter.outputs.code_changed }} + ids: ${{ steps.ids.outputs.ids }} steps: - uses: actions/checkout@v5 with: fetch-depth: 2 + # TSan is slow and still noisy, so pull requests run only ASan + UBSan; TSan runs on pushes, + # the weekly schedule and manual dispatch. + - id: ids + env: + EVENT: ${{ github.event_name }} + run: | + if [ "$EVENT" = "pull_request" ]; then + echo 'ids=["asan-ubsan"]' >> "$GITHUB_OUTPUT" + else + echo 'ids=["asan-ubsan","tsan"]' >> "$GITHUB_OUTPUT" + fi - id: filter env: EVENT: ${{ github.event_name }} @@ -57,9 +69,7 @@ jobs: sanitize: name: ${{ matrix.name }} needs: changes - # TSan is slow and still noisy, so pull requests run only ASan + UBSan; TSan runs on pushes, - # the weekly schedule and manual dispatch. - if: ${{ !cancelled() && needs.changes.outputs.code_changed != 'false' && (matrix.id != 'tsan' || github.event_name != 'pull_request') }} + if: ${{ !cancelled() && needs.changes.outputs.code_changed != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 120 env: @@ -69,9 +79,10 @@ jobs: strategy: fail-fast: false matrix: + id: ${{ fromJSON(needs.changes.outputs.ids) }} include: - - name: ASan + UBSan - id: asan-ubsan + - id: asan-ubsan + name: ASan + UBSan flags: -fsanitize=address,undefined -fno-sanitize-recover=undefined # Fibers switch stacks; the use-after-return fake stack does not follow them. control: | @@ -80,8 +91,8 @@ jobs: options: >- ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 - - name: TSan - id: tsan + - id: tsan + name: TSan flags: -fsanitize=thread control: | #include From 61c62475e3e6e967476080ea1f46ec283665983e Mon Sep 17 00:00:00 2001 From: ymatrix1 Date: Tue, 3 Nov 2026 20:25:17 -0300 Subject: [PATCH 4/8] ci: build the sanitizer matrix in the gate job, move controls to files --- .github/sanitizer-controls/asan.cpp | 7 ++++++ .github/sanitizer-controls/tsan.cpp | 9 +++++++ .github/workflows/sanitizers.yml | 37 ++++++----------------------- 3 files changed, 23 insertions(+), 30 deletions(-) create mode 100644 .github/sanitizer-controls/asan.cpp create mode 100644 .github/sanitizer-controls/tsan.cpp diff --git a/.github/sanitizer-controls/asan.cpp b/.github/sanitizer-controls/asan.cpp new file mode 100644 index 0000000000..893fa88e75 --- /dev/null +++ b/.github/sanitizer-controls/asan.cpp @@ -0,0 +1,7 @@ +// Heap overflow that returns 0 unless ASan intervenes. +int main(int c, char**) { + int* p = new int[4]; + volatile int v = p[c + 4]; + (void)v; + return 0; +} diff --git a/.github/sanitizer-controls/tsan.cpp b/.github/sanitizer-controls/tsan.cpp new file mode 100644 index 0000000000..b8e71511f5 --- /dev/null +++ b/.github/sanitizer-controls/tsan.cpp @@ -0,0 +1,9 @@ +// Data race that returns 0 unless TSan intervenes. +#include +int g; +int main() { + std::thread t([] { g = 1; }); + g = 2; + t.join(); + return 0; +} diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index bcfe722533..657b699e40 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -11,6 +11,7 @@ on: - ".patch/**" - "cpmfile.json" - ".github/workflows/sanitizers.yml" + - ".github/sanitizer-controls/**" push: branches: - nx2 @@ -45,10 +46,12 @@ jobs: env: EVENT: ${{ github.event_name }} run: | + asan='{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false}' + tsan='{"id":"tsan","name":"TSan","flags":"-fsanitize=thread","control":"tsan.cpp","diagnostic":"ThreadSanitizer","options":"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1","experimental":true}' if [ "$EVENT" = "pull_request" ]; then - echo 'ids=["asan-ubsan"]' >> "$GITHUB_OUTPUT" + echo "ids=[$asan]" >> "$GITHUB_OUTPUT" else - echo 'ids=["asan-ubsan","tsan"]' >> "$GITHUB_OUTPUT" + echo "ids=[$asan,$tsan]" >> "$GITHUB_OUTPUT" fi - id: filter env: @@ -79,30 +82,7 @@ jobs: strategy: fail-fast: false matrix: - id: ${{ fromJSON(needs.changes.outputs.ids) }} - include: - - id: asan-ubsan - name: ASan + UBSan - flags: -fsanitize=address,undefined -fno-sanitize-recover=undefined - # Fibers switch stacks; the use-after-return fake stack does not follow them. - control: | - int main(int c, char**) { int* p = new int[4]; volatile int v = p[c + 4]; (void)v; return 0; } - diagnostic: "AddressSanitizer|runtime error" - options: >- - ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 - UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 - - id: tsan - name: TSan - flags: -fsanitize=thread - control: | - #include - int g; - int main() { std::thread t([] { g = 1; }); g = 2; t.join(); return 0; } - diagnostic: "ThreadSanitizer" - options: TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1 - # Fiber code is a known source of TSan false positives until it is annotated. Only the - # test run is non-blocking; install, configure, build and registration still gate. - experimental: true + include: ${{ fromJSON(needs.changes.outputs.ids) }} steps: - name: Check out source @@ -130,10 +110,7 @@ jobs: # the flags or runtime are not actually sanitizing and a green test run means nothing. - name: Verify sanitizer catches a known bug run: | - cat > control.cpp <<'CPP' - ${{ matrix.control }} - CPP - g++ -g -O1 ${{ matrix.flags }} control.cpp -o control -pthread + g++ -g -O1 ${{ matrix.flags }} .github/sanitizer-controls/${{ matrix.control }} -o control -pthread # The control returns 0 unless a sanitizer intervenes, so require the report text too. env ${{ matrix.options }} ./control > control.log 2>&1 || true cat control.log From dff25b238b008834c02bcc376ce4ab29975f340e Mon Sep 17 00:00:00 2001 From: Yuri Olive Date: Tue, 3 Nov 2026 20:32:50 -0300 Subject: [PATCH 5/8] ci: fall back to a valid sanitizer matrix, cache the CPM directory CMake uses The matrix include used fromJSON on the gate job's ids output, which is empty if checkout fails before the ids step; use the same event-dependent choice as the fallback. CMakeLists.txt sets CPM_SOURCE_CACHE to .cache/cpm, overriding the environment variable, so cache that directory instead of .cpm-cache. --- .github/workflows/sanitizers.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 657b699e40..f8c27f3df4 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -78,11 +78,13 @@ jobs: env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 2000M - CPM_SOURCE_CACHE: ${{ github.workspace }}/.cpm-cache + CPM_SOURCE_CACHE: ${{ github.workspace }}/.cache/cpm strategy: fail-fast: false matrix: - include: ${{ fromJSON(needs.changes.outputs.ids) }} + # Falls back to the same event-dependent choice the ids step makes when its output is + # empty (checkout failed before it ran), so fromJSON never gets an empty string. + include: ${{ fromJSON(needs.changes.outputs.ids || (github.event_name == 'pull_request' && '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false}]' || '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false},{"id":"tsan","name":"TSan","flags":"-fsanitize=thread","control":"tsan.cpp","diagnostic":"ThreadSanitizer","options":"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1","experimental":true}]')) }} steps: - name: Check out source @@ -122,7 +124,7 @@ jobs: - name: Restore dependency sources uses: actions/cache@v4 with: - path: .cpm-cache + path: .cache/cpm key: cpm-${{ hashFiles('cpmfile.json', 'CMakeModules/**', '.patch/**') }} restore-keys: cpm- From 709e29f533f942d43c82ffd92da21c4f8108b140 Mon Sep 17 00:00:00 2001 From: Yuri Olive Date: Tue, 3 Nov 2026 21:09:34 -0300 Subject: [PATCH 6/8] ci: require a TSan data-race report from the control, restore CPM sources by exact key The TSan control matched the sanitizer name, which a fatal 'unexpected memory mapping' error also prints. Match the data-race report, in the ids step and the matrix fallback. A broad restore key could restore dependency sources from before a cpmfile.json change, so key the CPM cache exactly. --- .github/workflows/sanitizers.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index f8c27f3df4..ddbb79fc35 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -47,7 +47,7 @@ jobs: EVENT: ${{ github.event_name }} run: | asan='{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false}' - tsan='{"id":"tsan","name":"TSan","flags":"-fsanitize=thread","control":"tsan.cpp","diagnostic":"ThreadSanitizer","options":"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1","experimental":true}' + tsan='{"id":"tsan","name":"TSan","flags":"-fsanitize=thread","control":"tsan.cpp","diagnostic":"ThreadSanitizer.*data race","options":"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1","experimental":true}' if [ "$EVENT" = "pull_request" ]; then echo "ids=[$asan]" >> "$GITHUB_OUTPUT" else @@ -84,7 +84,7 @@ jobs: matrix: # Falls back to the same event-dependent choice the ids step makes when its output is # empty (checkout failed before it ran), so fromJSON never gets an empty string. - include: ${{ fromJSON(needs.changes.outputs.ids || (github.event_name == 'pull_request' && '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false}]' || '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false},{"id":"tsan","name":"TSan","flags":"-fsanitize=thread","control":"tsan.cpp","diagnostic":"ThreadSanitizer","options":"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1","experimental":true}]')) }} + include: ${{ fromJSON(needs.changes.outputs.ids || (github.event_name == 'pull_request' && '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false}]' || '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false},{"id":"tsan","name":"TSan","flags":"-fsanitize=thread","control":"tsan.cpp","diagnostic":"ThreadSanitizer.*data race","options":"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1","experimental":true}]')) }} steps: - name: Check out source @@ -125,8 +125,9 @@ jobs: uses: actions/cache@v4 with: path: .cache/cpm + # Exact key only: CPMUtil does not recheck a cached source's URL or hash, so a broad + # restore key could hand back sources from before a cpmfile.json change. key: cpm-${{ hashFiles('cpmfile.json', 'CMakeModules/**', '.patch/**') }} - restore-keys: cpm- - name: Configure run: | From 50e2825f097d9e4694c10d0acabf5b5d38445b73 Mon Sep 17 00:00:00 2001 From: ymatrix1 Date: Tue, 3 Nov 2026 21:22:04 -0300 Subject: [PATCH 7/8] ci: restore-only caches on PRs, mold linker, ccache stats --- .github/workflows/sanitizers.yml | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index ddbb79fc35..7313fc1eeb 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -99,10 +99,12 @@ jobs: libavfilter-dev libavcodec-dev libswscale-dev pkg-config zlib1g-dev \ libva-dev libvdpau-dev spirv-tools spirv-headers libusb-1.0-0-dev \ libboost-dev libboost-fiber-dev libboost-context-dev \ - libopus-dev libasound2-dev ccache catch2 + libopus-dev libasound2-dev ccache catch2 mold + # Pull requests only read the caches; nx2 pushes and the weekly run write them, so every PR + # starts from a warm nx2 cache instead of each one storing its own copy. - name: Restore ccache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: .ccache key: sanitizers-${{ matrix.id }}-${{ github.sha }} @@ -122,7 +124,8 @@ jobs: fi - name: Restore dependency sources - uses: actions/cache@v4 + id: cpm + uses: actions/cache/restore@v4 with: path: .cache/cpm # Exact key only: CPMUtil does not recheck a cached source's URL or hash, so a broad @@ -135,7 +138,7 @@ jobs: -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_C_FLAGS="${{ matrix.flags }} -g1 -fno-omit-frame-pointer" \ -DCMAKE_CXX_FLAGS="${{ matrix.flags }} -g1 -fno-omit-frame-pointer" \ - -DCMAKE_EXE_LINKER_FLAGS="${{ matrix.flags }}" \ + -DCMAKE_EXE_LINKER_FLAGS="${{ matrix.flags }} -fuse-ld=mold" \ -DBUILD_TESTING=ON \ -DYUZU_TESTS=ON \ -DENABLE_QT=OFF \ @@ -151,7 +154,10 @@ jobs: -DUSE_CCACHE=ON - name: Build tests - run: cmake --build build --target tests --parallel + run: | + ccache -z + cmake --build build --target tests --parallel + ccache -s - name: Verify tests are registered run: | @@ -161,3 +167,17 @@ jobs: - name: Run tests continue-on-error: ${{ matrix.experimental == true }} run: env ${{ matrix.options }} ctest --test-dir build --no-tests=error --output-on-failure + + - name: Save ccache + if: ${{ always() && github.ref == 'refs/heads/nx2' }} + uses: actions/cache/save@v4 + with: + path: .ccache + key: sanitizers-${{ matrix.id }}-${{ github.sha }} + + - name: Save dependency sources + if: ${{ always() && github.ref == 'refs/heads/nx2' && steps.cpm.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: .cache/cpm + key: cpm-${{ hashFiles('cpmfile.json', 'CMakeModules/**', '.patch/**') }} From dad14c28261083d49e9fe91d6ef541767c4aebb1 Mon Sep 17 00:00:00 2001 From: ymatrix1 Date: Tue, 3 Nov 2026 21:56:30 -0300 Subject: [PATCH 8/8] ci: build bundled zstd under ASan without its workspace poisoning zbic includes zstd.c from a C++ file, so zstd's own declaration of __asan_poison_memory_region gets C++ linkage and the tests binary fails to link under -fsanitize=address. ZSTD_ASAN_DONT_POISON_WORKSPACE turns that poisoning off. --- .github/workflows/sanitizers.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml index 7313fc1eeb..0d504eed4e 100644 --- a/.github/workflows/sanitizers.yml +++ b/.github/workflows/sanitizers.yml @@ -46,7 +46,7 @@ jobs: env: EVENT: ${{ github.event_name }} run: | - asan='{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false}' + asan='{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined -DZSTD_ASAN_DONT_POISON_WORKSPACE","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false}' tsan='{"id":"tsan","name":"TSan","flags":"-fsanitize=thread","control":"tsan.cpp","diagnostic":"ThreadSanitizer.*data race","options":"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1","experimental":true}' if [ "$EVENT" = "pull_request" ]; then echo "ids=[$asan]" >> "$GITHUB_OUTPUT" @@ -84,7 +84,7 @@ jobs: matrix: # Falls back to the same event-dependent choice the ids step makes when its output is # empty (checkout failed before it ran), so fromJSON never gets an empty string. - include: ${{ fromJSON(needs.changes.outputs.ids || (github.event_name == 'pull_request' && '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false}]' || '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false},{"id":"tsan","name":"TSan","flags":"-fsanitize=thread","control":"tsan.cpp","diagnostic":"ThreadSanitizer.*data race","options":"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1","experimental":true}]')) }} + include: ${{ fromJSON(needs.changes.outputs.ids || (github.event_name == 'pull_request' && '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined -DZSTD_ASAN_DONT_POISON_WORKSPACE","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false}]' || '[{"id":"asan-ubsan","name":"ASan + UBSan","flags":"-fsanitize=address,undefined -fno-sanitize-recover=undefined -DZSTD_ASAN_DONT_POISON_WORKSPACE","control":"asan.cpp","diagnostic":"AddressSanitizer|runtime error","options":"ASAN_OPTIONS=detect_stack_use_after_return=0:strict_string_checks=1:detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1","experimental":false},{"id":"tsan","name":"TSan","flags":"-fsanitize=thread","control":"tsan.cpp","diagnostic":"ThreadSanitizer.*data race","options":"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1","experimental":true}]')) }} steps: - name: Check out source