tests/ci: a race regression test that detects its race, and a Catch2 version that is checked rather than asserted (fixes #668, fixes #666, refs #667) - #673
Conversation
Runner verification#668's fix matches the mechanism, not just the symptom. My triage argued the old case missed because 8 threads × 400 posts is sustained saturation while the race needs a strand to reach empty. The new case is built on exactly that:
And the mutation table is the part that makes it a control rather than a test: 0 pass / 10 fail against the mutant under
The new gate works: #667 was mine to get wrong, and this branch is rightI triaged #667 v3.4.0 has the macro, and the artifact line has no Rewording the nine into a phrasing the gate reads, instead of editing a number, is the better fix — it converts prose into something checkable, which is the same move #659 made for AUTOMOC pairing. Two things before this can land
Filing the 🤖 Generated with Claude Code |
3006749 to
00ff98c
Compare
Runner: rebased onto
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Runner: the red legs are environmental, re-run requestedBoth runs are complete, so I read every failing job rather than inferring. Failing step, per leg: Not one of them is a compile, a test or a lint finding. They are package installs, a vcpkg checkout, and a dependency-cache configure — the same class as #672, which now has four distinct upstreams recorded in one day. The two signatures seen here:
Why I am confident this is not the branches: master's own 14:27Z run was Re-run requested. If a leg comes back red with a compile or test error, that is a real finding and I will treat it as one. 🤖 Generated with Claude Code |
…e load test never reaches the boundary (fixes #668) `tests/test_strand_race.cpp` is the named regression test for "a strand never runs two tasks for one key concurrently" -- the invariant most of `docs/spec/concurrency_and_lifetimes.md` rests on. It did not detect that invariant's regression. Reproduced before changing anything. With the pre-fix two-step drain restored in `scheduleNext` (flip `running` under `strand->mtx`, release it, then erase under `_mapMtx` in a separate critical section), on this branch's base `7a343e6f`, x86-64 Linux, clang 22.1.8, `clang-tsan` preset, `TSAN_OPTIONS=suppressions=cmake/tsan.supp`, binary carrying 173 `__tsan_` symbols: run 1..10: PASS All tests passed (41 assertions in 2 test cases) === [race] : 10 pass / 0 fail out of 10 === The miss is the load *shape*, not the load volume. The erase only fires when a drain finds the pending queue empty, and eight threads posting 3200 tasks back-to-back onto one key keep it non-empty almost throughout. The header comment's claim that "very short tasks maximise the drain/re-arm interleaving" is false in the direction that matters: they maximise re-arm, and never produce the quiet moment a drain needs. That claim is corrected in place. The new case manufactures the rendezvous instead of hoping for it. A pilot task is posted alone on the key and publishes the round number as its last act; four chaser threads spin on that publication and post the instant it flips, i.e. while the drain block following the pilot's body is deciding keep-running vs. erase, each with a per-thread stagger that walks the post across that window. A round ends only once every one of its tasks has run, so the strand really does empty before the next pilot. Three detectors, because the defect and its symptom are not the same event: `maxInFlight` (kept -- the symptom); plain, non-atomic state touched by every task (the defect: two strands for one key leave those accesses unordered by any happens-before edge, which TSan reports whether or not the tasks overlap, and whose lost updates are visible with no sanitizer at all); and per-producer FIFO, which an orphaned strand breaks without any two tasks overlapping. Mutation-tested, with the parameters as committed: clang-tsan, mutant, 10 runs: 0 pass / 10 fail 2-3 TSan data races per run; outOfOrder 65-114; maxInFlight 2 clang-debug (no sanitizer), 15 runs: 0 pass / 15 fail outOfOrder 1-9; maxInFlight 2 clang-tsan, mutant, pinned to 4 CPUs: 0 pass / 10 fail clang-tsan, mutant, pinned to 2 CPUs: 0 pass / 10 fail the OLD case, same mutant binary, 10 runs: 10 pass / 0 fail And it does not flake on the fixed tree: `[race]` passes 20/20 under `clang-tsan` and 20/20 under `clang-debug`, plus the full `morph_tests` binary (1562 cases, 23007 assertions). Cost: 1.0 s under TSan on 4 CPUs, 1.3 s on 2, 0.098 s without a sanitizer -- against the suite's `TIMEOUT 120`. `include/morph/core/strand.hpp` is untouched; the mutant was applied to a scratch copy and reverted (md5 verified against the pristine file). Not verified: the `Kanban / ThreadSanitizer` CI leg, which needs Qt and the ladder, and every non-Linux leg. The 2-CPU run is the closest stand-in here for a four-core runner under load. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ixes #666, refs #667) `apt-get install -y catch2` pins nothing, and which Catch2 is on the include path decides whether a `readability-function-cognitive-complexity` finding on a `TEST_CASE` body reaches the clang-tidy-diff job at all. So a workstation can run the same clang-tidy over the same diff with the same job flags and exit 0 where CI exits 1 -- silently, reporting nothing rather than reporting less (#666). Nothing in the tree stated which Catch2 the job had, except nine hand-written copies of a sentence that nothing checked. `scripts/check_catch2_pin.sh` has two halves, and only the second one is new in kind: A. Textual, modelled on `scripts/check_ci_clang_pin.sh` (#457) down to the rule-B rewording trap and the anti-vacuity failure: every `CI pins catch2 <V>` in the tree must name `CATCH2_VERSION` from ci.yml, and any other line naming a Catch2 version beside a CI reference is rejected as a phrasing the gate cannot check. B. Behavioural: the Catch2 headers actually present are read and compared against that pin. Under `--strict`, which is how the clang-tidy job runs it, a moved runner package -- or no Catch2 at all -- fails the job before it measures anything. Without `--strict`, a workstation run prints the divergence and says in as many words that its clang-tidy-diff result is not the measurement CI makes. `scripts/test_check_catch2_pin.sh` drives both halves from the wrong side as well as the right one: 13 cases, every drift the gate claims to catch reintroduced one at a time into a scratch git tree or a synthetic include directory, each required to fail *for the stated reason*, plus four false-positive mirrors. Run here: ok: the unmodified tree passes against the pinned Catch2 ok: caught: a doc asserting CI pins catch2 3.5.3 while ci.yml pins 3.4.0 ok: caught: ci.yml bumped to 3.5.3 while the nine copies still say 3.4.0 ok: caught: a CI Catch2 claim in an unrecognised phrasing, even with the right version ok: caught: a CI Catch2 claim in an unrecognised phrasing with the wrong version ok: caught: every canonical assertion removed from the tree ok: caught: ci.yml with no CATCH2_VERSION to read ok: accepted: a Catch2 version named with no CI reference on the line ok: accepted: a historical record carrying the documented marker ok: caught: --strict against a runner whose Catch2 package has moved ok: caught: --strict with no Catch2 installed at all ok: accepted: a workstation whose Catch2 differs is warned, not failed ok: accepted: a workstation whose Catch2 matches the pin is not warned ok: all catch2-pin checker self-tests passed The version is 3.4.0, which is what the nine `.clang-tidy` copies already said -- #667 is wrong and is not "fixed" here. ubuntu-24.04's package is 3.4.0-1build1 (Launchpad, noble Release pocket, no -updates publication), and the artifact line both tickets read as 3.5.x is v3.4.0's text exactly: v3.4.0 catch_test_registry.hpp:121 INTERNAL_CATCH_TESTCASE2( INTERNAL_CATCH_UNIQUE_NAME( dummyFunction ) ) v3.5.3 catch_test_registry.hpp:121 INTERNAL_CATCH_TESTCASE2( INTERNAL_CATCH_UNIQUE_NAME( dummyFunction ), __VA_ARGS__ ) The artifact has no `, __VA_ARGS__`. Both tickets rest on "3.4.0 has no `CATCH_CONFIG_EXPERIMENTAL_STATIC_ANALYSIS_SUPPORT`, so it cannot produce `dummyFunctionNN`"; v3.4.0 has it, at `catch_test_registry.hpp:77/91/124`. The nine copies are therefore reworded rather than corrected: same fact, in the canonical phrasing the gate reads, so the number is checked instead of asserted. What this does not do, because it matters that the line is drawn: it does not make a local clang-tidy-diff agree with CI's. Only the job not depending on the runner's Catch2 does that -- #666's first closing condition, which needs a CMake change in a file this branch does not own, and is filed separately rather than folded in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
`clang-tidy-diff` reported one finding on the new race case:
tests/test_strand_race.cpp:289:40: error: use a ranges version of this
algorithm [modernize-use-ranges,-warnings-as-errors]
`std::minmax_element(cells.begin(), cells.end())` becomes
`std::ranges::minmax_element(cells)`. `<algorithm>` was already included, and
the result is an aggregate of two iterators rather than a pair, so the
structured binding and both dereferences are unchanged -- verified by
compiling the same shape standalone under clang 22.1.8 `-std=c++23 -Werror`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
00ff98c to
f619158
Compare
Runner: rebased onto
|
…ownload that blamed tar, and a sanitizer matrix the bank example was never in (fixes #675, fixes #679, refs #672) (#683) * ci: let the sanitizer-instrumentation check answer "is this one binary instrumented?", without lowering the floor that makes its sweep mean something (fixes #675) `scripts/check_sanitizer_instrumentation.sh` conflated two questions behind one floor. "Did this check examine a representative set?" needs the floor, and that is the CI invocation. "Is this one binary instrumented?" is a yes/no about a single file and needs no floor at all -- but the script refused it: only examined 1 binaries -- too few for this check to mean anything so a developer who had built one target under a sanitizer preset answered it by hand with `nm | grep __tsan_`, re-deriving the per-mode symbol table and the SIGPIPE trap the script already encodes. The #673 lane did exactly that. `--binary <file> <mode>` answers the second question against the same symbol table and skips the floor. The constraint that matters is that it must not be usable where the floor was meant to apply, and that is enforced structurally rather than by convention: the mode refuses outright, exit 2, when GITHUB_ACTIONS is set, so no step of any workflow in this repository can reach it. The sweep is unchanged -- same floor, same message, same exit codes. Both halves are pinned by a new self-test, following the repository's scripts/test_check_*.sh convention and running in drift-guard.yml's sanitizer-can-fail job. It needs no compiler beyond `cc` and no sanitizer runtime: the gate's whole measurement is `nm | grep -c __<mode>_`, so a fixture that merely defines a function of that name exercises the gate rather than clang, and a hand-written CTestTestfile.cmake is all `ctest --show-only` needs. Measured, not asserted. The self-test's two load-bearing cases were confirmed to fail against a mutated checker on this revision: - guard replaced by `if false` -> error: --binary ran under GITHUB_ACTIONS -- the floor can now be bypassed from a workflow step: 1 self-test check(s) failed - floor lowered from 2 to 1 -> error: the sweep accepted a one-binary tree -- the floor has been lowered: 1 self-test check(s) failed and all twelve cases pass on the unmutated script. Not verified: the narrow mode against a real sanitizer-instrumented binary -- the fixtures carry the symbol name, not a sanitizer runtime. That is deliberate (it keeps the self-test in a dependency-free job), and the sweep path, which shares the same `count_symbols` helper, is exercised against real instrumented binaries by the three CI legs that already run it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW * ci: make a failed download say it failed, instead of letting tar report it as "not in gzip format" (refs #672) On 2026-09-21 both GCC legs of PR #671 died in the `Install sccache` step with gzip: stdin: not in gzip format tar: Child returned status 1 tar: Error is not recoverable: exiting now ##[error]Process completed with exit code 2 Nothing compiled, and nothing in the log named the download. The step piped `curl -sSL <url>` straight into `tar -xz`, and plain curl treats an HTTP 4xx or 5xx as a *successful* transfer of whatever body came back -- so an error page went down the pipe and the decompressor was the only thing that complained. `set -o pipefail` would not have helped: GitHub runs `run:` under `bash -e` without it, so the pipeline's status is tar's regardless. Reproduced locally against a server that returns 503, the old shape and the new one, both under `bash -e`: =========== OLD SHAPE, 503 (bash -e, no pipefail) =========== gzip: stdin: not in gzip format tar: Child returned status 1 tar: Error is not recoverable: exiting now exit=2 =========== NEW SHAPE, 503 =========== curl: (22) The requested URL returned error: 503 exit=22 =========== NEW SHAPE, 200 =========== exit=0 total 4 -rwxr-xr-x 1 yaraslau yaraslau 28 Sep 21 18:36 sccache -- the third block being the success path over a tarball with the real release's member layout, so `--strip-components=1` still lands the binary in place. Applied to all nine copies of the step in ci.yml (the first carries the full reasoning, the other eight point at it), and to the two other downloads in this repository with the identical defect and the identical one-flag fix: docs.yml's Doxygen tarball and mutation.yml's Mull .deb. Those two are the same finding, not a separate one -- the Mull case is measurably worse, since a 503 puts 64 bytes of HTML in `${asset}` and `dpkg-deb` is left to object to the archive. Deliberately NOT in scope, and #672 stays open for them: - a retry policy for dependency installation; - a CI-wide marker that distinguishes "the environment failed" from "the change failed" in the check list. Both are decisions rather than implementations, and neither is needed for the one case where the log actively misled. The three apt/PPA outages that make up the rest of #672 are untouched by this. Found while doing it, filed rather than folded: `wget -qO- https://apt.llvm.org/ llvm.sh | sudo bash` (nine sites in ci.yml, one in mutation.yml) fails the other way. Measured against the same 503 server: `wget -qO-` exits 8 and writes zero bytes, `bash` reads empty input and exits 0, and the pipeline exits 0 -- a clang-installation step that reports success having installed nothing. Filed separately because it is a different failure shape needing a different fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW * ci/bank: put the bank example under a sanitizer, and instrument the targets that were never going to carry one (fixes #679) Measured on 5fc5e78: `linux-sanitizers`, `kanban-tsan`, `ladder-sanitizers` and `valgrind` set zero `MORPH_BUILD_BANK_*` flags, against eight places elsewhere in ci.yml that set `MORPH_BUILD_BANK_EXAMPLE=ON`. Bank is not a rung, so `MORPH_BUILD_LADDER=ON` does not reach it either. Nothing was written down about excluding it; it was an option nobody turned on. Two halves, and the second was not in the ticket. **1. bank's targets never called `apply_sanitizers()`.** `ladder_bank_server` was the only one that did. So flipping the CI flag alone would not have instrumented anything -- it would have built `bank_lib`, `bank_cli` and all three test binaries blind. Measured, on a `clang-ubsan` configure with bank on and the blocks absent: ::error::check_sanitizer_instrumentation: 3 of 9 ctest binaries are not ubsan-instrumented naming bank_tests, bank_gui_tests and bank_gui_qml_tests. With the blocks in place the same sweep reports 9 of 9. The blocks are also required *together*: removing bank_tests' alone, with bank_lib's kept, does not merely leave the binary unchecked, it fails to link with `undefined reference to __ubsan_handle_type_mismatch_v1_abort`. **2. a `bank-sanitizers` job (clang-ubsan, Qt + bank + GUI)**, rather than a flag on `linux-sanitizers`' clang-ubsan leg, which is what the ticket proposed. Two measured reasons. That leg builds no Qt and its own comment reserves the matrix against GUI stacks, while bank's GUI is where the UB was. And, cold and cacheless on 12 cores with clang 22.1.8: leg's current shape (core + net + offline_sqlite, no Qt) configure 36s build 144s 144 ninja edges this job's shape (core + Qt + bank + bank GUI) configure 64s build 391s 287 ninja edges Folding one into the other roughly triples the slowest leg of a three-leg matrix, whose duration is then the matrix's. Split out, **the three existing legs' flags are not changed at all, so the wall-clock delta on them is zero**, and this runs beside them. Same precedent and same argument as kanban-tsan. Bank's isolated build cost -- the number the ticket asked for -- is the difference between this job and the same configure without bank (51s / 151s / 150 edges): **+13s configure, +240s build, +137 edges**. `ubsan` rather than `asan`: UBSan diagnoses this class, and ASan over a Qt GUI needs the `detect_leaks=0` and suppression story ladder-sanitizers carries, which this job would have to acquire before it could be believed. Bank under ASan is not closed by this. **The first bill is zero.** Per the morph#646 (84) and morph#656 (97) precedents, measured before landing: bank_tests (145 assertions in 21 cases), bank_gui_tests (19 in 5) and bank_gui_qml_tests (32 in 2) all pass clean under `-fsanitize=undefined -fno-sanitize-recover=undefined`, and so does the whole 1696-test suite of this configure (212s serial, 100% passed, zero `runtime error` lines). Nothing is suppressed and no allowlist entry was added. **A finding that cuts against the ticket's framing, stated rather than shipped around.** The ticket says this gap is why morph#663 survived to be found by reading code. Half true. Rebuilding Format.hpp as it stood before the fix, in this job's exact configuration, bank_gui_tests exits 1 with examples/bank/gui/controllers/Format.hpp:76:38: runtime error: 9.2e+19 is outside the range of representable values of type 'long' -- but only because morph#663's fix also added the test that calls parseMinor with such a value. With the pre-morph#663 header *and* the pre-morph#663 test set, this leg is green: no pre-existing bank test drove that path. The sanitizer gap was real and is what this closes; it was not on its own what let morph#663 through, and this job's reach is bounded by how much of bank the suites actually drive. That is written into the job's banner, not just here. Also here, because the job needs it: a `bank` ctest label on the three suites, so the leg runs bank's 28 tests (6s) rather than re-running the 1696 (212s) that linux-sanitizers' clang-ubsan leg already runs under identical instrumentation. A label filter that matched nothing would be this repository's named failure mode; CMakePresets.json's `base-test` already sets `noTestsAction: error`, confirmed to exit 8 with `No tests were found!!!` against a tree built without bank. Not verified: bank under ASan or TSan; whether the Lightweight ORM is clean under anything other than UBSan; and the job's real duration on a hosted runner, which adds an apt install and a Qt install this local measurement does not model. Found while measuring, filed rather than folded: bank's 21 ctest cases share one SQLite file and cannot run concurrently -- `ctest -j 12` fails 21 of them with `[SQLite]disk I/O error (10)` where the same binaries pass serially. CI runs ctest serially (no test preset sets a parallel level), so it is latent rather than active. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW * ci: pin install-qt-action to v4.3.1, so a moving major tag cannot break Qt `Bank example / UBSan` failed with the Qt install step rejected before any build ran: The template is not valid. jurplel/install-qt-action/v4/action.yml (Line: 160, Col: 11): Expected format {org}/{repo}[/path]@ref. Actual '$/action' Upstream's action.yml now contains `uses: $/action` -- GitHub's self-repository syntax, added in their 2026-07-30 changelog. Some runner images resolve it and some do not. In the same workflow run, `Linux / Qt6 WebSockets` started 17:13:34Z with a byte-identical invocation and passed; this job started 17:23:29Z and did not. Ten minutes and a different runner apart, with no change on our side. `@v4` is a major alias that upstream moves on every release, so this repository has no say in when that syntax arrives. `v4.3.1` is the last release whose action.yml does not use it (v4.4.0 and v4.4.1 both do), and it declares every input used here -- arch, cache, dir, host, modules, target, version. All 11 call sites across ci.yml, wasm-demo.yml and wasm-ladder.yml are pinned together, because a partial pin leaves the same lottery running on whichever job was missed. Verified: no floating `@v4` remains; all seven workflows parse; banner-lint, option-coverage and catch2-pin pass. This does not fix the class -- a pinned tag is still a tag, and the durable answer is a commit SHA. That trade (immutability against a version nobody can read) is recorded in morph#672 rather than decided here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three tickets about controls that report success while measuring nothing. Two
commits. One of the three turns out to be false, and is not "fixed" here —
see the #667 section, which is the part worth reading first if you only read
one.
invariant does not detect that invariant's regression. Reproduced, rewritten,
mutation-tested in both directions.
fixes #668.clang-tidy-diffis silently green on a diff CI fails,and nothing records or checks which Catch2 the job measured against. A gate
now does.
fixes #666..clang-tidycopies name the wrong Catch2 version. Theydo not. The runner's Catch2 is 3.4.0, and both tickets' shared premise is
false.
refs #667; the copies are reworded so the number is checked ratherthan merely asserted, and the ticket is answered with the evidence.
#668 — commit 1
Reproduced the miss before touching anything, as the ticket asks
Two-step drain restored in
scheduleNext(fliprunningunderstrand->mtx,release it, then erase under
_mapMtxin a separate critical section), base7a343e6f, x86-64 Linux, clang 22.1.8,clang-tsanpreset,TSAN_OPTIONS=suppressions=cmake/tsan.supp:Not a blind run: the binary carries 173
__tsan_symbols.(
scripts/check_sanitizer_instrumentation.shrefuses a build directory holdingone binary —
only examined 1 binaries ... too few for this check to mean anything— so the symbol count is read directly, since onlymorph_testswasbuilt.)
The finding is not stale. It reproduces exactly as filed.
The miss is the load shape
The erase only fires when a drain finds the pending queue empty, and eight
threads posting 3200 tasks back-to-back onto one key keep it non-empty almost
throughout. The header comment's "very short tasks maximise the drain/re-arm
interleaving that triggered the bug" is false in the direction that matters:
they maximise re-arm, and never produce the quiet moment a drain needs. That
sentence is corrected in place, with the measurement beside it.
The new case manufactures the rendezvous
A pilot task is posted alone on the key and publishes the round number as its
last act. Four chaser threads spin on that publication and post the instant it
flips — i.e. while the drain block following the pilot's body is deciding
keep-running vs. erase — each with a per-thread stagger that walks its post
across the handful of instructions that decision spans. A round ends only once
every one of its tasks has run, so the strand genuinely empties before the next
pilot. The gap is the point.
Three detectors, because the defect and its symptom are not the same event:
maxInFlight(kept from the old case)Mutation-tested, with the parameters as committed
The TSan report names the mechanism:
Two strand lambdas for one key writing the same plain
int.And it does not flake on the fixed tree
[race]passes 20/20 underclang-tsanand 20/20 underclang-debug; thefull
morph_testsbinary passes (1562 cases, 23007 assertions, the onefailed as expected). Cost: 1.0 s under TSan on 4 CPUs, 1.3 s on 2,0.098 s without a sanitizer, against the suite's
TIMEOUT 120.include/morph/core/strand.hppis untouched (PR #671 holds it). The mutantwas applied to the file and reverted from a byte-identical backup;
md5sumchecked against the pristine copy after each cycle, and
git diff --statforthe branch names no header.
#666 + #667 — commit 2
Establishing the version first, as instructed — and it settles #667 the other way
apt-cache policy catch2is not available here (Arch workstation), so twoindependent sources:
Launchpad,
ubuntu/+archive/primaryfornoble:No
-updatesor-securitypublication. 3.4.0-1build1.The CI artifact both tickets read as 3.5.x, checked against the upstream
tarballs:
The artifact prints it without
, __VA_ARGS__— v3.4.0's text exactly, andthe one line #667 itself flags as not matching 3.5.3.
catch_test_macros.hpp:142and
catch_unique_name.hpp:12/13/15match v3.4.0 too; they are simplyunchanged between the two releases, so they never distinguished them.
The premise both tickets rest on is false. #667: "
dummyFunctionexistsonly inside
#ifdef CATCH_CONFIG_EXPERIMENTAL_STATIC_ANALYSIS_SUPPORT, whichCatch2 did not have in 3.4.0 — it arrives in 3.5.0." v3.4.0 has it, at
catch_test_registry.hpp:77/91/124, and#cmakedefines it incatch_user_config.hpp.in:172. So the nine copies were right all along, and#667 meets its own invalidation condition verbatim — "the naming mechanism is
not what anyone thinks".
Two smaller corrections to #667's text while we are here: the sentence is in
nine files, and
tests/.clang-tidy(which the title names) is not oneof them — it is the eight
examples/*/tests/.clang-tidyplusexamples/common/testkit/.clang-tidy.What actually needed fixing
#666's divergence is real and its measurement stands: which Catch2 is on the
include path decides whether a
readability-function-cognitive-complexityfinding on a
TEST_CASEbody reaches the job. What was missing is that nothingrecorded or checked the version, and
apt-get install -y catch2pins nothing.scripts/check_catch2_pin.sh, modelled onscripts/check_ci_clang_pin.sh(#457) down to the rule-B rewording trap and the anti-vacuity failure:
CI pins catch2 <V>must name ci.yml's newCATCH2_VERSION; any other line naming a Catch2 version beside a CIreference is rejected as a phrasing the gate cannot check; zero canonical
assertions is a failure, not a pass.
on the include path. Under
--strict— how the clang-tidy job runs it, rightafter the apt install and before the configure — a moved runner package, or
no Catch2 at all, fails the job before it measures anything. Without
--strict, a workstation run prints the divergence and says in as many wordsthat its clang-tidy-diff result is not the measurement CI makes:
scripts/test_check_catch2_pin.shdrives both halves from the wrong side aswell as the right one — 13 cases, each drift reintroduced one at a time into a
scratch git tree or a synthetic include directory and required to fail for the
stated reason, plus four false-positive mirrors. All 13 pass; the two that
matter most:
The nine copies are reworded, not corrected: same fact, in the canonical
phrasing the gate reads, so the number is now checked rather than asserted —
which is what #667's triage actually wanted from nine hand-written copies of a
version.
What this deliberately does not do
It does not make a local clang-tidy-diff agree with CI's. Only the job not
depending on the runner's Catch2 does that (#666's first closing condition):
have the clang-tidy configure use the Catch2
CMakeLists.txtalready pins forFetchContent instead of apt's. That needs
examples/common/CMakeLists.txt,which PR #665 holds, and it is filed separately rather than folded in.
Nothing here tells a contributor to run clang-tidy differently — the local half
reports a measurement, the CI half fails a job.
The one claim this branch rests on
Everything else on the branch is a gate over prose and a package version.
Review notes, done inline
CHECKs are allinvariants the spec states unconditionally (at most one task in flight per
key, per-producer FIFO, no lost updates to state the strand is the only
synchronisation for). None depends on a deadline or a task count reached in
time: the round barrier is a synchronisation point, and
~StrandExecutoristhe final drain, exactly as the neighbouring case's comment argues. 40 clean
runs across two build configurations found none.
pool threads work. The spin yields once per 4096 iterations as a starvation
guard; pinned to 2 CPUs the case takes 1.3 s rather than 1.0 s and still
catches the mutant 10/10, so the guard costs the rendezvous nothing.
handoff between two tasks for one key passes through
_mapMtx/strand->mtxand the pool's own queue mutex, so each task's writes happen-before the next
task's reads. 20 TSan runs report zero races; the mutant reports 2-3 per run.
writing it: exactly one line in the tree names a Catch2 version by the gate's
regex (
docs/superpowers/specs/2026-09-15-opt-in-logging-design.md:119), andit carries no CI reference, so it is out of scope by construction. The
self-test pins that as an
expect_acceptedcase.Gates run here
check_mutation_survivors.py,check_nolint_directives.sh,check_bidi_controls.py,check_rung_filters.sh(58 checks, including allnine reworded
.clang-tidyfiles),check_spec_citations.sh,check_ci_clang_pin.sh,check_workflow_job_banners.py,check_workflow_option_coverage.py,check_catch_test_names.sh,check_test_type_names.sh,check_ctest_name_collisions.sh build/clang-debug,test_check_catch2_pin.sh, andclang-format --dry-run -Werrorovertests/test_strand_race.cpp. All pass.Not verified: the
--stricthalf has never run on an actual ubuntu-24.04runner — it is exercised here against synthetic include directories, and this
PR's own clang-tidy leg is its first real execution. If it fails there, the
interesting outcome is what version it prints. Also not verified: the
Kanban / ThreadSanitizerleg and every non-Linux leg of the new test case.🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW