ci: master's red bank-ubsan leg — ctest enumerates bank's PRE_TEST suites headless, and the checker was hiding why (fixes #690, refs #691) - #692
Conversation
#690) `check_sanitizer_instrumentation.sh` enumerated the build tree with ctest --test-dir "${build_dir}" --show-only=json-v1 2>/dev/null which made the two ways that list can come back empty indistinguishable: ctest enumerated a tree that registers no tests, and ctest failed before printing any JSON at all. Both arrive at the same "listed no tests" error, and the sentence that tells them apart was being thrown away one pipe away from the message that needed it. That cost three CI runs and two local sessions on the bank-ubsan leg (#690): the cause was already being printed on every failing run. ctest's stderr now goes to a file rather than /dev/null and its exit status is kept. On the empty-list path the checker reports the status, the stdout byte count, and either the captured stderr or an explicit statement that there was none -- which is the distinction itself, since a listing that merely found nothing is silent and one that failed is not. The stream still stays off stdout: it is not JSON and jq would choke on it. Measured on the real bank-ubsan tree at 24a470c, headless, which is what the runner is: ::error::check_sanitizer_instrumentation: ctest listed no tests in build/clang-ubsan -- this check would pass having examined nothing check_sanitizer_instrumentation: `ctest --show-only=json-v1` exited 8 and wrote 0 bytes of stdout. check_sanitizer_instrumentation: its stderr follows -- a non-empty stderr here means the listing *failed*, not that the tree registers no tests: | CMake Error at /usr/lib/cmake/Catch2/CatchAddTests.cmake:307 (message): | Error listing tests from executable | '.../build/clang-ubsan/examples/bank/bank_gui_qml_tests': | | Result: Subprocess aborted Two self-test cases hold it. Case 3 now also asserts that a genuinely empty tree is *named* as empty rather than reading like a failure, and case 3b drives a CTestTestfile.cmake that fails while being read -- ctest exits nonzero with an empty stdout, the shape #690 had -- and asserts the fixture's own marker string reaches the caller. Asserting on the fixture's marker rather than on ctest's wording is what makes it a test of the pass-through and not of ctest. Verified by mutation: with this commit's script change reverted and the self-test left in place, both cases fail -- error: the failed listing was rejected, but ctest's own reason was discarded -- the caller is left with 'listed no tests' and no cause, which is morph#690 2 self-test check(s) failed and all 13 pass with it. The self-test is run in CI by drift-guard.yml. This is the diagnostic half of #690 and stands on its own: it does not make the bank-ubsan leg green. The next commit does that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ffscreen platform (fixes #690) `Bank example / UBSan` has failed on every run since it landed -- twice on master, once on PR #689 -- at the step before its tests: ::error::check_sanitizer_instrumentation: ctest listed no tests in build/clang-ubsan -- this check would pass having examined nothing The guard was right and is untouched. What it could not examine, and why, is the whole of the defect. The sweep's first act is `ctest --show-only=json-v1`, and ctest is exactly where bank's discovery runs. Bank's three suites are the only Qt-linked targets in this repository registered with `DISCOVERY_MODE PRE_TEST`; every other Qt suite uses POST_BUILD, where the Build step's `QT_QPA_PLATFORM=offscreen` already covers the enumeration run. PRE_TEST moves that run to ctest time, so *listing* the tests executes `bank_gui_qml_tests --list-tests`, whose main constructs a QGuiApplication (examples/common/testkit/testkit_main.cpp) before Catch2 parses the flag. With no display and no QT_QPA_PLATFORM it aborts, Catch2's CatchAddTests.cmake turns a nonzero discovery into `message(FATAL_ERROR ...)`, and ctest exits 8 having printed no JSON at all -- not bank's entries missing, the entire listing, every other suite with it. Nine binaries became zero. The step now declares the same `QT_QPA_PLATFORM: offscreen` the Test step below it already declares, which is the principle rather than a patch: this sweep's subject is the binaries that step will run, so it has to enumerate them in that step's environment. Verification status: **reproduced locally and fixed locally**, on this configure at 24a470c (clang 22.1.8, Catch2 3.16.0, Qt 6.11.2; the runner's versions differ, the code path does not). The runner's one distinguishing property is that it is headless, so that is what was emulated. Same tree, same build, one environment variable apart: $ env -u DISPLAY -u WAYLAND_DISPLAY \ bash scripts/check_sanitizer_instrumentation.sh build/clang-ubsan ubsan ::error::check_sanitizer_instrumentation: ctest listed no tests in build/clang-ubsan -- this check would pass having examined nothing `ctest --show-only=json-v1` exited 8 and wrote 0 bytes of stdout. | CMake Error at .../CatchAddTests.cmake:307 (message): | Error listing tests from executable | '.../examples/bank/bank_gui_qml_tests': | Result: Subprocess aborted $ env -u DISPLAY -u WAYLAND_DISPLAY QT_QPA_PLATFORM=offscreen \ bash scripts/check_sanitizer_instrumentation.sh build/clang-ubsan ubsan check_sanitizer_instrumentation: 9 ctest binaries all carry __ubsan_ symbols (0 allowlisted). and the binary itself, directly: $ env -u DISPLAY -u WAYLAND_DISPLAY ./bank_gui_qml_tests --list-tests exit 134 (SIGABRT), no output $ env -u DISPLAY -u WAYLAND_DISPLAY QT_QPA_PLATFORM=offscreen \ ./bank_gui_qml_tests --list-tests 2 test cases The step after it was then run under the same conditions -- headless, offscreen, UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 -- and `ctest -L bank` reports 29/29 in 1.81s, so the leg has somewhere to go once the sweep lets it through. This also explains why two earlier sessions could not reproduce the failure and reported "9 ctest binaries all carry __ubsan_ symbols": a workstation has a display, so the enumeration succeeds there whether or not the variable is set. It is the one difference between the runner and a workstation that this step was sensitive to. Two comments are corrected alongside it, both of which state the fact that was missed: - The Build step's note claimed catch_discover_tests runs "each Qt-linked test binary" at build time. In this configure that is morph_qt_tests and not bank's suites, which is precisely the gap. - examples/bank/CMakeLists.txt's `PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen"` reads as though it makes the suite headless-safe. It does not: PROPERTIES are set on the tests Catch2 registers, and the run that finds out what those tests are happens first. CatchAddTests.cmake's execute_process() forwards only DL_PATHS and DL_FRAMEWORK_PATHS into it, never ENVIRONMENT. What would change the verdict: if the leg still fails after this, the first commit's diagnostic now prints ctest's own reason, so the next failure arrives named rather than opaque. Fixes #690 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Runner verification — root cause confirmed, and the guard is untouchedBoth load-bearing links check out. Why No Qt at all. So it never builds a Qt-linked And the abort's origin, in So The guard is intact — verified by running it, not by reading the diff: That second sentence is the fix to the diagnostic gap. The floor still refuses; it now says which of the two ways it got nothing, which is precisely what three sessions could not tell from CI. On the structure of the changeLanding the stderr capture as its own commit, first, was right — and your framing is the part I want to keep: it "would have paid for itself even if commit 2 were wrong." A diagnostic improvement that stands independent of the fix it enabled is worth more than the fix, because the next opaque failure in that script is now named rather than guessed at. Mutation-testing it (revert the script, two self-test cases fail) is what makes that a control rather than a comment. Reproducing by removing #691 is the better outcome of this ticketThree of the four sanitizer sweep steps still run Filing that as a latent hazard — mechanism reproduced, those jobs' exposure explicitly marked as inferred — rather than folding a speculative fix into this PR is the right call. The Not verified by me: the headless reproduction, the 29/29 🤖 Generated with Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
masteris red onBank example / UBSan, and has been on every run since the job landed in #683 — twice onmaster, once on PR #689. This makes it green, and makes the next failure of its kind arrive named instead of opaque.Two commits, two concerns.
1.
scripts:print why ctest listed no tests instead of discarding itcheck_sanitizer_instrumentation.shenumerated the tree withctest --show-only=json-v1 2>/dev/null, which made two different failures indistinguishable: ctest enumerated a tree that registers no tests and ctest failed before printing any JSON. Both surfaced asctest listed no tests, with the sentence that tells them apart discarded one pipe away from the error message. On #690 it was the second, and the cause had been printed on every one of the three failing runs.stderr now goes to a file and the exit status is kept; on the empty-list path the checker prints the status, the stdout byte count, and either the captured stderr or an explicit statement that there was none — which is the distinction, since a listing that found nothing is silent and one that failed is not.
Two self-test cases hold it, and the mutation check was run: with the script change reverted and the self-test left in place, both fail —
— and all 13 pass with it.
drift-guard.ymlruns that self-test in CI.This commit alone does not make the leg green. It is the half that would have paid for itself even if the second half were wrong.
2.
ci:the bank-ubsan sweep enumerates ctest headless, so it needs the offscreen platformThe sweep's first act is
ctest --show-only=json-v1, and ctest is exactly where bank's discovery runs. Bank's three suites are the only Qt-linked targets in this repository registered withDISCOVERY_MODE PRE_TEST; every other Qt suite usesPOST_BUILD, where the Build step'sQT_QPA_PLATFORM=offscreenalready covers the enumeration run.PRE_TESTmoves that run to ctest time, so listing the tests executesbank_gui_qml_tests --list-tests, whosemainconstructs aQGuiApplication(examples/common/testkit/testkit_main.cpp) before Catch2 ever sees the flag. Headless and with noQT_QPA_PLATFORMit aborts;CatchAddTests.cmaketurns a nonzero discovery intomessage(FATAL_ERROR ...); ctest exits 8 having printed no JSON at all — not bank's entries missing, the entire listing, every other suite with it. Nine binaries became zero.The step now declares the same
QT_QPA_PLATFORM: offscreentheTeststep below it already declares. That is the principle and not a patch: this sweep's subject is the binaries that step will run, so it has to enumerate them in that step's environment.The guard is untouched. #675's floor did its job here — it refused to report success having examined nothing, and it is the only reason this was visible at all. Nothing in this branch relaxes it; the diff to
check_sanitizer_instrumentation.shadds output on the failing path and changes no verdict.Verification
Reproduced locally and fixed locally, on the real
clang-ubsanbank configure at24a470c4(clang 22.1.8, Catch2 3.16.0, Qt 6.11.2 — the runner's versions differ, the code path does not). Configured from an empty build directory. The runner's one distinguishing property is that it is headless, so that is what was emulated. Same tree, same build, one environment variable apart:The first block is the CI failure, locally, for the first time. The second is the fix.
The binary itself, directly:
And the step that has never run — headless, offscreen,
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1, as the workflow sets it:So the leg has somewhere to go once the sweep lets it through.
Why two earlier sessions could not reproduce this and reported
9 ctest binaries all carry __ubsan_ symbols: a workstation has a display, so the enumeration succeeds there whether or not the variable is set. That is the runner-versus-workstation difference, and it is the whole of it.What I did not verify
v3.8.1viamorph_cache_dep, or the distrocatch2the bank job installs from apt. Theexecute_process+FATAL_ERRORpath inCatchAddTests.cmakeis long-standing, and the CI symptom (ctest listed no tests, after a successful Build) is consistent with the same path, but I did not read 3.8.1's copy.The one claim this branch rests on
That adding
QT_QPA_PLATFORM=offscreento a step cannot make anything worse. It is inert for every binary that needs no platform integration, it is the value the adjacentTeststep already sets, and the only behaviour it changes is which Qt platform pluginbank_gui_qml_testsloads during a--list-testsrun that currently aborts.Review notes, done inline
check_sanitizer_instrumentation.sh's verdicts are unchanged — the diff adds output on the already-failing path and amktemp/trappair. The floor atchecked < 2, the empty-list refusal, the per-mode symbol table and the--binaryrefusal underGITHUB_ACTIONSare all untouched, and the self-test still passes all 13 cases including the floor ones.ctestrun leave a stale discovery cache theTeststep then reuses? Yes, it caches<target>_tests.cmake, and that is fine: both steps now run with the identicalQT_QPA_PLATFORM, so the cached listing is the one the Test step would have produced. Before this change the sweep step left no cache at all, because it failed.set -ecorrectness. The ctest invocation uses|| ctest_status=$?rather than a command substitution, so a nonzero exit is captured instead of aborting the script.jqreads from the captured file rather than a pipe, sopipefailhas nothing to trip over.morph_qt_tests, not bank's suites — precisely the gap), andexamples/bank/CMakeLists.txt'sPROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen", which reads as though it makes the suite headless-safe. It does not:PROPERTIESapply to the tests Catch2 registers, andCatchAddTests.cmake'sexecute_process()forwards onlyDL_PATHS/DL_FRAMEWORK_PATHSinto the discovery run.Filed, not folded
ctest --show-onlywithout their own Test step's environment.kanban-tsanandladder-asansetoffscreenon Build and Test and omit it on the sweep step between them; they are green only because every Qt-linked suite they build happens to usePOST_BUILD. One word changed anywhere turns them red with the same uninformative message. Latent hazard, not an outage; exposure inferred from reading, not reproduced.Fixes #690
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW